GraphQL’de JWT ile Kullanıcı Kimlik Doğrulama

Modern API geliştirmede güvenlik, her şeyin önünde gelir. GraphQL’in esnek yapısı güçlü bir araç olsa da, bu esneklik beraberinde ciddi güvenlik sorumluluklarını da getiriyor. JWT (JSON Web Token) tabanlı kimlik doğrulama, GraphQL API’larında kullanıcı oturumlarını yönetmenin en yaygın ve sağlam yollarından biri. Bu yazıda, sıfırdan bir GraphQL + JWT sistemi kuracağız, gerçek dünya senaryolarına bakacağız ve sık yapılan hataları ele alacağız.

JWT Nedir ve GraphQL’de Neden Kullanırız?

JWT, kullanıcı kimlik bilgilerini güvenli bir şekilde taşıyan, imzalanmış bir token formatıdır. Üç bölümden oluşur: Header, Payload ve Signature. Bu yapı sayesinde sunucu, her istekte veritabanına gitmeden token’ı doğrulayabilir.

GraphQL’de REST’ten farklı olarak tüm istekler tek bir endpoint üzerinden geçer (/graphql). Bu durum hem avantaj hem de dezavantaj yaratır. Avantajı, merkezi bir güvenlik katmanı uygulamak kolaylaşır. Dezavantajı ise her resolver’ı ayrı ayrı korumak gerekebilir.

JWT’nin GraphQL ile uyumu mükemmeldir çünkü:

  • Stateless yapısıyla horizontal scaling destekler
  • Her resolver’da context üzerinden kullanıcı bilgisine erişilir
  • Microservice mimarilerinde token payload’ı servisler arası taşınabilir

Proje Kurulumu

Node.js tabanlı bir GraphQL sunucusu kuracağız. Apollo Server ve Express kombinasyonunu kullanacağız.

mkdir graphql-jwt-demo
cd graphql-jwt-demo
npm init -y
npm install apollo-server-express express jsonwebtoken bcryptjs graphql
npm install --save-dev nodemon dotenv

Proje yapısını oluşturalım:

mkdir -p src/{resolvers,middleware,models,utils}
touch src/index.js src/schema.js src/context.js
touch src/utils/auth.js src/middleware/authenticate.js
touch .env

.env dosyasına gerekli değişkenleri ekleyelim:

cat > .env << 'EOF'
PORT=4000
JWT_SECRET=super_gizli_anahtar_buraya_yaz_minimum_32_karakter
JWT_REFRESH_SECRET=refresh_icin_ayri_secret_kullan
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
EOF

Önemli not: Production ortamında JWT secret’ı en az 256 bit (32 karakter) uzunluğunda ve rastgele oluşturulmuş olmalı. openssl rand -base64 32 komutuyla güvenli bir secret üretebilirsiniz.

GraphQL Schema Tanımı

Kullanıcı kimlik doğrulama için gerekli type’ları ve mutation’ları tanımlayalım:

cat > src/schema.js << 'EOF'
const { gql } = require('apollo-server-express');

const typeDefs = gql`
  type User {
    id: ID!
    email: String!
    username: String!
    role: String!
    createdAt: String!
  }

  type AuthPayload {
    accessToken: String!
    refreshToken: String!
    user: User!
  }

  type TokenPayload {
    accessToken: String!
  }

  type Query {
    me: User
    users: [User!]!
    protectedData: String!
  }

  type Mutation {
    register(
      email: String!
      username: String!
      password: String!
    ): AuthPayload!

    login(
      email: String!
      password: String!
    ): AuthPayload!

    refreshToken(
      refreshToken: String!
    ): TokenPayload!

    logout: Boolean!
  }
`;

module.exports = typeDefs;
EOF

JWT Yardımcı Fonksiyonları

Token oluşturma ve doğrulama işlemlerini merkezi bir yerde toplamak, kodun bakımını kolaylaştırır:

cat > src/utils/auth.js << 'EOF'
const jwt = require('jsonwebtoken');
require('dotenv').config();

const generateAccessToken = (user) => {
  return jwt.sign(
    {
      userId: user.id,
      email: user.email,
      role: user.role
    },
    process.env.JWT_SECRET,
    {
      expiresIn: process.env.JWT_EXPIRES_IN,
      issuer: 'graphql-demo-api',
      audience: 'graphql-demo-client'
    }
  );
};

const generateRefreshToken = (user) => {
  return jwt.sign(
    {
      userId: user.id,
      tokenVersion: user.tokenVersion || 0
    },
    process.env.JWT_REFRESH_SECRET,
    {
      expiresIn: process.env.JWT_REFRESH_EXPIRES_IN
    }
  );
};

const verifyAccessToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'graphql-demo-api',
      audience: 'graphql-demo-client'
    });
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      throw new Error('TOKEN_EXPIRED');
    }
    throw new Error('TOKEN_INVALID');
  }
};

const verifyRefreshToken = (token) => {
  try {
    return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
  } catch (error) {
    throw new Error('REFRESH_TOKEN_INVALID');
  }
};

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyAccessToken,
  verifyRefreshToken
};
EOF

Burada dikkat edilmesi gereken önemli nokta, issuer ve audience alanlarının kullanılmasıdır. Bu alanlar token’ın hangi servis tarafından üretildiğini ve hangi servis için geçerli olduğunu belirtir. Farklı microservice’leriniz varsa bu kontrol kritik öneme sahiptir.

Context ve Authentication Middleware

GraphQL’in gücü context mekanizmasında yatıyor. Her resolver aynı context’e erişebildiği için kullanıcı bilgisini bir kere çözümleyip her yerde kullanabilirsiniz:

cat > src/context.js << 'EOF'
const { verifyAccessToken } = require('./utils/auth');

const createContext = ({ req }) => {
  const authHeader = req.headers.authorization || '';
  
  let currentUser = null;
  
  if (authHeader.startsWith('Bearer ')) {
    const token = authHeader.slice(7);
    
    try {
      const decoded = verifyAccessToken(token);
      currentUser = {
        userId: decoded.userId,
        email: decoded.email,
        role: decoded.role
      };
    } catch (error) {
      // Token gecersiz veya suresi dolmus
      // Null birakilir, resolver seviyesinde kontrol edilir
      console.warn(`Auth hatasi: ${error.message}`);
    }
  }
  
  return {
    currentUser,
    req
  };
};

module.exports = createContext;
EOF

Resolver’ların Yazılması

Şimdi asıl iş kısmına geliyoruz. In-memory bir kullanıcı deposu kullanacağız, gerçek projede bunu bir veritabanıyla değiştirirsiniz:

cat > src/resolvers/index.js << 'EOF'
const bcrypt = require('bcryptjs');
const { AuthenticationError, UserInputError, ForbiddenError } = require('apollo-server-express');
const {
  generateAccessToken,
  generateRefreshToken,
  verifyRefreshToken
} = require('../utils/auth');

// Basit in-memory veritabani (production'da PostgreSQL/MongoDB kullanin)
const users = [];
let idCounter = 1;
const refreshTokenStore = new Set(); // Gecerli refresh token'lari tutar

// Yetkilendirme yardimci fonksiyonu
const requireAuth = (currentUser) => {
  if (!currentUser) {
    throw new AuthenticationError('Bu islemi yapabilmek icin giris yapmaniz gerekiyor.');
  }
  return currentUser;
};

const requireRole = (currentUser, role) => {
  requireAuth(currentUser);
  if (currentUser.role !== role && currentUser.role !== 'admin') {
    throw new ForbiddenError('Bu isleme yetkiniz yok.');
  }
};

const resolvers = {
  Query: {
    me: (_, __, { currentUser }) => {
      requireAuth(currentUser);
      return users.find(u => u.id === currentUser.userId);
    },

    users: (_, __, { currentUser }) => {
      requireRole(currentUser, 'admin');
      return users.map(u => ({ ...u, password: undefined }));
    },

    protectedData: (_, __, { currentUser }) => {
      requireAuth(currentUser);
      return `Merhaba ${currentUser.email}, bu korunan bir veridir!`;
    }
  },

  Mutation: {
    register: async (_, { email, username, password }) => {
      // Email kontrolu
      const existingUser = users.find(u => u.email === email);
      if (existingUser) {
        throw new UserInputError('Bu email zaten kayitli.');
      }

      // Sifre gucluluğu kontrolu
      if (password.length < 8) {
        throw new UserInputError('Sifre en az 8 karakter olmalidir.');
      }

      const hashedPassword = await bcrypt.hash(password, 12);
      
      const newUser = {
        id: String(idCounter++),
        email,
        username,
        password: hashedPassword,
        role: users.length === 0 ? 'admin' : 'user', // Ilk kullanici admin
        tokenVersion: 0,
        createdAt: new Date().toISOString()
      };
      
      users.push(newUser);

      const accessToken = generateAccessToken(newUser);
      const refreshToken = generateRefreshToken(newUser);
      refreshTokenStore.add(refreshToken);

      return {
        accessToken,
        refreshToken,
        user: { ...newUser, password: undefined }
      };
    },

    login: async (_, { email, password }) => {
      const user = users.find(u => u.email === email);
      
      if (!user) {
        // Zamanlama saldirilarini onlemek icin her zaman hash karsilastirmasi yap
        await bcrypt.compare(password, '$2a$12$placeholder.hash.to.prevent.timing');
        throw new AuthenticationError('Gecersiz email veya sifre.');
      }

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

      const accessToken = generateAccessToken(user);
      const refreshToken = generateRefreshToken(user);
      refreshTokenStore.add(refreshToken);

      return {
        accessToken,
        refreshToken,
        user: { ...user, password: undefined }
      };
    },

    refreshToken: (_, { refreshToken }) => {
      if (!refreshTokenStore.has(refreshToken)) {
        throw new AuthenticationError('Gecersiz refresh token.');
      }

      const decoded = verifyRefreshToken(refreshToken);
      const user = users.find(u => u.id === decoded.userId);
      
      if (!user || user.tokenVersion !== decoded.tokenVersion) {
        throw new AuthenticationError('Token gecersiz hale getirilmis.');
      }

      const newAccessToken = generateAccessToken(user);
      return { accessToken: newAccessToken };
    },

    logout: (_, { refreshToken }, { currentUser, req }) => {
      requireAuth(currentUser);
      const token = req.headers['x-refresh-token'];
      if (token) {
        refreshTokenStore.delete(token);
      }
      return true;
    }
  }
};

module.exports = resolvers;
EOF

Ana Sunucu Dosyası

cat > src/index.js << 'EOF'
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const typeDefs = require('./schema');
const resolvers = require('./resolvers');
const createContext = require('./context');
require('dotenv').config();

async function startServer() {
  const app = express();
  
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    context: createContext,
    formatError: (error) => {
      // Production'da stack trace'leri gizle
      if (process.env.NODE_ENV === 'production') {
        return {
          message: error.message,
          code: error.extensions?.code
        };
      }
      return error;
    },
    introspection: process.env.NODE_ENV !== 'production'
  });

  await server.start();
  server.applyMiddleware({ app, path: '/graphql' });

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

startServer().catch(console.error);
EOF

API’yi Test Etmek

Sunucuyu başlatıp curl ile test edelim:

# Sunucuyu baslat
npm run dev

# Yeni kullanici kaydı
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -d '{
    "query": "mutation { register(email: "[email protected]", username: "testuser", password: "Sifre123!") { accessToken refreshToken user { id email role } } }"
  }'

# Token ile korunan endpoint
ACCESS_TOKEN="yukaridan_alinan_access_token"
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -H "Authorization: Bearer $ACCESS_TOKEN" 
  -d '{"query": "query { me { id email username role } }"}'

# Token yenileme
REFRESH_TOKEN="yukaridan_alinan_refresh_token"
curl -X POST http://localhost:4000/graphql 
  -H "Content-Type: application/json" 
  -d "{"query": "mutation { refreshToken(refreshToken: \"$REFRESH_TOKEN\") { accessToken } }"}"

Role Tabanlı Erişim Kontrolü

Gerçek dünya uygulamalarında basit “giriş yaptı / yapmadı” kontrolü yetmez. Directive tabanlı RBAC yaklaşımı çok daha temiz bir çözüm sunar:

cat > src/directives/auth.js << 'EOF'
const { SchemaDirectiveVisitor } = require('apollo-server-express');
const { defaultFieldResolver } = require('graphql');
const { AuthenticationError, ForbiddenError } = require('apollo-server-express');

class AuthDirective extends SchemaDirectiveVisitor {
  visitFieldDefinition(field) {
    const { resolve = defaultFieldResolver } = field;
    const { requires } = this.args;

    field.resolve = async function (source, args, context, info) {
      if (!context.currentUser) {
        throw new AuthenticationError('Giris yapmaniz gerekiyor.');
      }

      if (requires && context.currentUser.role !== requires && context.currentUser.role !== 'admin') {
        throw new ForbiddenError(`Bu alan icin ${requires} rolü gerekiyor.`);
      }

      return resolve.call(this, source, args, context, info);
    };
  }
}

module.exports = AuthDirective;
EOF

Schema’ya directive tanımını eklerseniz şu şekilde kullanabilirsiniz:

# Schema ornegi directive kullanimi
cat >> src/schema.js << 'EOF'

# Directive tanimlamalari schema'nin basina eklenmeli
directive @auth(requires: String) on FIELD_DEFINITION

type Query {
  adminPanel: String @auth(requires: "admin")
  userDashboard: String @auth
}
EOF

Güvenlik İpuçları ve Yaygın Hatalar

Üretime geçmeden önce mutlaka kontrol etmeniz gereken güvenlik noktaları:

Access token süresini kısa tutun: 15 dakika ideal bir süredir. Çalınan bir token’ın etkisini minimize eder.

Refresh token rotation uygulayın: Her refresh işleminde eski token’ı geçersiz kılıp yeni bir tane üretin. Bu sayede token çalınması durumunda fark edilebilir.

Rate limiting ekleyin: Login endpoint’i brute force saldırılarına karşı savunmasız kalabilir.

npm install express-rate-limit graphql-depth-limit

# Rate limiting konfigurasyon ornegi
cat > src/middleware/rateLimit.js << 'EOF'
const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 dakika
  max: 5,
  message: 'Cok fazla basarisiz giris denemesi. 15 dakika sonra tekrar deneyin.',
  standardHeaders: true,
  legacyHeaders: false,
});

const generalLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 dakika
  max: 100,
  message: 'Cok fazla istek gonderildi.'
});

module.exports = { loginLimiter, generalLimiter };
EOF

Query depth limitlemesi: GraphQL’de nested query’ler sunucuyu yorabilir, graphql-depth-limit paketiyle maksimum derinliği sınırlandırın.

Token’ları asla localStorage’da saklamayın: HttpOnly cookie kullanın. JavaScript erişimine kapalıdır, XSS saldırılarına karşı çok daha güvenlidir.

Introspection’ı production’da kapatın: Schema yapısı saldırganlara yol haritası çıkarır. introspection: false ayarını production’da aktif edin.

Hassas hata mesajları vermeyin: “Kullanıcı bulunamadı” yerine “Geçersiz email veya şifre” deyin. Hangi bilginin yanlış olduğunu söylemek, enumeration saldırılarına kapı aralar.

Production Deployment Kontrol Listesi

Sunucuyu production’a almadan önce kontrol edilmesi gerekenler:

  • JWT_SECRET en az 32 karakter, rastgele üretilmiş
  • NODE_ENV=production ortam değişkeni tanımlı
  • Introspection kapalı
  • Rate limiting aktif
  • HTTPS zorunlu kılınmış
  • CORS ayarları sadece güvenilen originlere izin veriyor
  • Refresh token’lar veritabanında saklanıyor (Redis önerilir)
  • Token blacklist mekanizması mevcut
  • Logging sistemi kurulu (başarısız auth denemeleri loglanıyor)
  • Dependency’ler güncel (npm audit temiz çıkıyor)

Sonuç

GraphQL ve JWT kombinasyonu, modern API güvenliği için güçlü bir temel oluşturuyor. Bu yazıda temel kayıt ve giriş akışından role tabanlı yetkilendirmeye, refresh token yönetiminden production güvenlik önlemlerine kadar geniş bir kapsam ele aldık.

En çok dikkat edilmesi gereken nokta şu: GraphQL’in tek endpoint yapısı, güvenliği daha merkezi hale getirir ama aynı zamanda daha kritik bir nokta oluşturur. Context mekanizmasını doğru kullanmak, resolver seviyesinde tutarlı yetkilendirme kontrolleri yapmak ve token yönetimini doğru uygulamak, sağlam bir sistemin temel taşlarıdır.

Gerçek projelerde in-memory store yerine Redis ile refresh token yönetimi, Prisma veya TypeORM ile veritabanı entegrasyonu ve graphql-shield kütüphanesi ile daha gelişmiş yetkilendirme katmanları eklemenizi öneririm. Güvenlik tek seferlik bir iş değil, sürekli güncellenen ve gözden geçirilen bir süreçtir.

Bir yanıt yazın

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