Implementowanie uwierzytelniania użytkowników w Express.js przy użyciu tokenów JWT

GraphQL, stanowiący popularną alternatywę dla tradycyjnej architektury REST API, oferuje elastyczny język zapytań i manipulacji danymi. W obliczu rosnącej popularności, zabezpieczenie interfejsów API GraphQL staje się kluczowe dla ochrony aplikacji przed nieuprawnionym dostępem i potencjalnymi wyciekami informacji.

Jedną z efektywnych metod zabezpieczania API GraphQL jest zastosowanie tokenów JSON Web Tokens (JWT). JWT umożliwiają bezpieczne i efektywne nadawanie dostępu do chronionych zasobów oraz realizację autoryzowanych akcji, zapewniając bezpieczną komunikację pomiędzy klientami a API.

Uwierzytelnianie i Autoryzacja w GraphQL API

W przeciwieństwie do API REST, interfejsy API GraphQL posiadają zwykle jeden punkt dostępu, umożliwiając klientom dynamiczne żądanie różnorodnych danych za pomocą zapytań. Pomimo elastyczności, stwarza to także potencjalne ryzyko ataków bezpieczeństwa, takich jak luki związane z kontrolą dostępu.

Aby zminimalizować to ryzyko, kluczowe jest wdrożenie solidnych mechanizmów uwierzytelniania i autoryzacji, w tym precyzyjne zdefiniowanie uprawnień dostępu. Tym sposobem zapewnisz, że tylko zweryfikowani użytkownicy będą mieli dostęp do chronionych zasobów, zmniejszając ryzyko potencjalnych naruszeń bezpieczeństwa i utraty danych.

Kod tego projektu jest dostępny w repozytorium GitHub.

Konfiguracja serwera Apollo Express.js

Serwer Apollo jest powszechnie wykorzystywaną implementacją serwera GraphQL dla interfejsów API. Ułatwia on tworzenie schematów GraphQL, definiowanie funkcji resolverów oraz zarządzanie różnorodnymi źródłami danych dla API.

Aby skonfigurować serwer Apollo Express.js, stwórz folder projektu i przejdź do niego:

 mkdir graphql-API-jwt
cd graphql-API-jwt

Następnie, używając menedżera pakietów Node (npm), zainicjuj nowy projekt Node.js:

 npm init --yes 

Teraz zainstaluj niezbędne pakiety:

 npm install apollo-server graphql mongoose jsonwebtokens dotenv 

Na koniec, utwórz plik server.js w głównym katalogu i skonfiguruj serwer, używając poniższego kodu:

 const { ApolloServer } = require('apollo-server');
const mongoose = require('mongoose');
require('dotenv').config();

const typeDefs = require("./graphql/typeDefs");
const resolvers = require("./graphql/resolvers");

const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({ req }),
});

const MONGO_URI = process.env.MONGO_URI;

mongoose
.connect(MONGO_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => {
console.log("Connected to DB");
return server.listen({ port: 5000 });
})
.then((res) => {
console.log(`Server running at ${res.url}`);
})
.catch(err => {
console.log(err.message);
});

Serwer GraphQL jest konfigurowany z wykorzystaniem parametrów typeDefs i resolverów, które definiują strukturę schematu i operacje obsługiwane przez API. Opcja kontekstu konfiguruje obiekt req w kontekście każdego resolvera, umożliwiając serwerowi dostęp do szczegółów konkretnego żądania, w tym nagłówków.

Tworzenie bazy danych MongoDB

Aby połączyć się z bazą danych, należy najpierw utworzyć bazę MongoDB lub skonfigurować klaster w MongoDB Atlas. Następnie skopiuj uzyskany ciąg URI połączenia z bazą danych, stwórz plik .env i wprowadź parametry połączenia w następujący sposób:

 MONGO_URI="<mongo_connection_uri>"

Definiowanie modelu danych

Zdefiniuj model danych przy użyciu biblioteki Mongoose. Stwórz nowy plik models/user.js i dodaj następujący kod:

 const {model, Schema} = require('mongoose');

const userSchema = new Schema({
name: String,
password: String,
role: String
});

module.exports = model('user', userSchema);

Definiowanie schematu GraphQL

W GraphQL API schemat określa strukturę danych, do których można kierować zapytania, a także udostępnia dostępne operacje (zapytania i mutacje), które umożliwiają interakcję z danymi za pośrednictwem API.

Aby zdefiniować schemat, stwórz nowy folder w głównym katalogu projektu i nazwij go graphql. W tym folderze umieść dwa pliki: typeDefs.js i resolvers.js.

W pliku typeDefs.js umieść poniższy kod:

 const { gql } = require("apollo-server");

const typeDefs = gql`
type User {
id: ID!
name: String!
password: String!
role: String!
}
input UserInput {
name: String!
password: String!
role: String!
}
type TokenResult {
message: String
token: String
}
type Query {
users: [User]
}
type Mutation {
register(userInput: UserInput): User
login(name: String!, password: String!, role: String!): TokenResult
}
`;

module.exports = typeDefs;

Tworzenie resolverów dla GraphQL API

Funkcje resolverów określają, w jaki sposób dane są pobierane w odpowiedzi na zapytania i mutacje klientów, jak również na podstawie pól zdefiniowanych w schemacie. Gdy klient przesyła zapytanie lub mutację, serwer GraphQL uruchamia odpowiednie resolvery, aby przetworzyć i zwrócić wymagane dane z różnych źródeł, takich jak bazy danych czy inne API.

Aby zaimplementować uwierzytelnianie i autoryzację za pomocą tokenów JWT, zdefiniuj resolvery dla mutacji rejestracji i logowania. Będą one obsługiwać procesy rejestracji i weryfikacji użytkowników. Następnie stwórz resolver do pobierania danych, który będzie dostępny tylko dla uwierzytelnionych i autoryzowanych użytkowników.

Najpierw zdefiniuj funkcje generowania i weryfikacji tokenów JWT. Zacznij od dodania poniższych importów w pliku resolvers.js:

 const User = require("../models/user");
const jwt = require('jsonwebtoken');
const secretKey = process.env.SECRET_KEY;

Upewnij się, że w pliku .env umieściłeś klucz tajny używany do podpisywania tokenów JWT.

 SECRET_KEY = '<mój_tajny_klucz>'; 

Do generowania tokenu uwierzytelniania dodaj poniższą funkcję, która definiuje unikatowe atrybuty tokenu JWT, w tym czas jego ważności. Można również dodać inne atrybuty, takie jak czas wydania tokenu, w zależności od wymagań aplikacji:

 function generateToken(user) {
const token = jwt.sign(
{ id: user.id, role: user.role },
secretKey,
{ expiresIn: '1h', algorithm: 'HS256' }
);

return token;
}

Teraz zaimplementuj logikę weryfikacji tokenu, aby sprawdzić tokeny JWT dołączane do żądań HTTP:

 function verifyToken(token) {
if (!token) {
throw new Error('Token not provided');
}

try {
const decoded = jwt.verify(token, secretKey, { algorithms: ['HS256'] });
return decoded;
} catch (err) {
throw new Error('Invalid token');
}
}

Ta funkcja przyjmuje token jako argument, weryfikuje jego poprawność przy użyciu określonego klucza tajnego i zwraca zdekodowany token, jeśli jest ważny. W przeciwnym przypadku zgłasza błąd informujący o nieprawidłowym tokenie.

Definiowanie resolverów API

Aby zdefiniować resolvery dla GraphQL API, należy określić konkretne operacje, które będą zarządzane, w tym przypadku rejestrację i logowanie użytkownika. Najpierw utwórz obiekt funkcji resolverów, który będzie przechowywał funkcje resolverów, a następnie zdefiniuj poniższe operacje mutacji:

 const resolvers = {
Mutation: {
register: async (_, { userInput: { name, password, role } }) => {
if (!name || !password || !role) {
throw new Error('Name password, and role required');
}

const newUser = new User({
name: name,
password: password,
role: role,
});

try {
const response = await newUser.save();

return {
id: response._id,
...response._doc,
};
} catch (error) {
console.error(error);
throw new Error('Failed to create user');
}
},
login: async (_, { name, password }) => {
try {
const user = await User.findOne({ name: name });

if (!user) {
throw new Error('User not found');
}

if (password !== user.password) {
throw new Error('Incorrect password');
}

const token = generateToken(user);

if (!token) {
throw new Error('Failed to generate token');
}

return {
message: 'Login successful',
token: token,
};
} catch (error) {
console.error(error);
throw new Error('Login failed');
}
}
},

Mutacja rejestracji zarządza procesem dodawania nowych danych użytkownika do bazy danych. Z kolei mutacja logowania obsługuje proces uwierzytelniania – po pomyślnej weryfikacji generuje token JWT i zwraca komunikat o powodzeniu.

Teraz dodaj moduł resolvera zapytania, który będzie odpowiedzialny za pobieranie danych użytkownika. Aby zapewnić, że to zapytanie będzie dostępne tylko dla uwierzytelnionych i autoryzowanych użytkowników, dodaj logikę autoryzacji ograniczającą dostęp do użytkowników z rolą administratora.

W praktyce zapytanie najpierw weryfikuje ważność tokenu, a następnie rolę użytkownika. Jeśli kontrola autoryzacji zakończy się powodzeniem, zapytanie resolvera pobierze i zwróci dane użytkowników z bazy danych.

   Query: {
users: async (parent, args, context) => {
try {
const token = context.req.headers.authorization || '';
const decodedToken = verifyToken(token);

if (decodedToken.role !== 'Admin') {
throw new ('Unauthorized. Only Admins can access this data.');
}

const users = await User.find({}, { name: 1, _id: 1, role:1 });
return users;
} catch (error) {
console.error(error);
throw new Error('Failed to fetch users');
}
},
},
};

Na koniec uruchom serwer deweloperski:

 node server.js 

Świetnie! Teraz możesz przetestować działanie API za pomocą piaskownicy API serwera Apollo w przeglądarce. Możesz na przykład użyć mutacji rejestracji do dodania nowych danych użytkownika do bazy danych, a następnie mutacji logowania, aby uwierzytelnić użytkownika.

Na koniec dodaj token JWT do sekcji nagłówka autoryzacji i kontynuuj wysyłanie zapytań do bazy danych o dane użytkownika.

Zabezpieczanie API GraphQL

Uwierzytelnianie i autoryzacja to fundamentalne elementy zabezpieczania API GraphQL. Trzeba jednak pamiętać, że same w sobie mogą nie być wystarczające do zapewnienia pełnego bezpieczeństwa. Zaleca się wdrożenie dodatkowych środków bezpieczeństwa, takich jak walidacja danych wejściowych i szyfrowanie wrażliwych informacji.

Stosując kompleksowe podejście do bezpieczeństwa, można skutecznie chronić swoje API przed różnorodnymi potencjalnymi zagrożeniami.