Jak napisać aplikację, która pozwoli na uwierzytelnienie użytkownika wykorzystując tokeny JWT

Wprowadzenie – kilka słów o uwierzytelnieniach

Uwierzytelnienie jest niczym innym jak weryfikacją tożsamości danego użytkownika, w celu dostępu do chronionych zasobów. W przypadku aplikacji, strona pyta o login oraz hasło zdefiniowane wcześniej przez użytkownika. Jeśli wartości się zgadzają, istnieje prawdopodobieństwo, że dany użytkownik jest tym, za którego się podaje. W kolejnym etapie dochodzimy do autoryzacji użytkownika, czyli sprawdzenia, czy ma on odpowiednie uprawienia do konkretnych zasobów serwisu. Przykładowo wyobraźmy sobie taką sytuację – jesteśmy użytkownikiem, który ma konto w danym serwisie. Logując się na tym serwisie zachodzi proces uwierzytelnienia, natomiast sprawdzenie, czy jesteśmy uprawnieni do uzyskania dostępu do konkretnego zasobu strony – jest autoryzacją. 

Jakie informacje znajdziesz w poradniku?

W poniższym przewodniku skupimy się na praktycznym przedstawieniu sposobu tworzenia kompletnej aplikacji, która pozwoli nam na uwierzytelnienie użytkownika. Pokazana zostanie metoda napisania prostego klienta przy pomocy Vue.js oraz API, które będzie składać się z uwierzytelniania używającego tokenów JWT.

Poradnik został podzielony na dwie części. W pierwszej zajmiemy się stworzeniem API, natomiast w drugiej wykreujemy klienta, w którym użyjemy naszych interfejsów API.

Wszystkie pliki potrzebne do zbudowania aplikacji znajdziesz tutaj!

W obu częściach przewodnika przekazane zostaną informacje, na czym należy się skupić przechodząc przez kolejne kroki oraz jak napisać aplikację, aby była użyteczna.

Od czego zacząć – API

Nasze API będzie prostą aplikacją napisaną w Node.js wykorzystująca Express.js do obsługi żądań oraz tokeny JWT.

Express

Express to minimalny i elastyczny framework do tworzenia aplikacji internetowych node.js oraz interfejsów API, który zawiera zestaw wielu funkcji. Dzięki temu mamy możliwość w szybki i prosty sposób stworzyć aplikację do obsługi naszego API.

JSON Web Token 

JSON Web Token (JWT) to otwarty standard (RFC 7519), który określa kompaktowy i niezależny sposób bezpiecznego przesyłania informacji między stronami jako obiekt JSON.  

Zakodowane informacje można weryfikować, ponieważ podpisywane są cyfrowo z wykorzystaniem klucza tajnego (z algorytmem HMAC ) lub pary kluczy “publiczny/prywatny” przy użyciu RSA lub ECDSA . Dlatego tokeny te często używane są do autoryzacji, kiedy jeden z serwisów chce nadać stronie dostęp do zasobów, a później bez ich przechowywania weryfikować, czy dostęp do zasobu nadal jest osiągalny. 

Token składa się z trzech części oddzielonych kropkami, dlatego wygląda tak: xxxxx.yyyyy.zzzzz. 

Wyjaśnijmy czym są poszczególne części tokenu: 

  • Nagłówek (Header) – zwiera informacje o tokenie: jakiego algorytmu używa oraz jakiego typu jest ów token. Postać wynikowa, czyli obiekt JSON zmieniany jest na zapis Base64. 
  • Zawartość (Payload) – przechowuje dane, czyli roszczenia, które dotyczą jednostki (użytkownik) oraz dodatkowych danych. Istnieją trzy rodzaje roszczeń: zarejestrowane, publiczne i prywatne. Przechowują informację o ważności tokenu, czasie utworzenia oraz roli użytkownika. Aby dowiedzieć się więcej warto zajrzeć do dokumentacji. Ładunek kodowany jest Base64Url w celu utworzenia drugiej części tokenu. 
  • Sygnatura (Signature) – podpis służy do weryfikacji, czy wiadomość nie została zmieniona po drodze oraz w przypadku tokenów podpisanych kluczem prywatnym istnieje możliwość weryfikacji, czy nadawca jest tym za kogo się podaje. 

W poradniku został wybrany algorytm haszowania HMAC SHA256, dlatego sygnatura tworzona będzie w następujący sposób: 

HMACSHA256(base64UrlEncode(header) + ’.’ + base64UrlEncode(payload), secret)

“Secret” to hasło do haszowania sygnatury. Powinno być skomplikowane oraz składać się z wielu znaków, ponieważ jeżeli ktoś je złamie, będzie wstanie podszyć się pod serwis autoryzacyjny.

MongoDB

Dane użytkowników będą przechowywane w bazie danych MongoDB.

MongoDB to nierelacyjna baza danych oparta na dokumentach JSON, co oznacza że przechowuje dane w dokumentach podobnych do JSON. Dzięki czemu jest bardziej wyrazista i wydajna. MongoDB ma duży język zapytań, który pozwala filtrować, przeszukiwać oraz sortować dane, bez względu na to, jak bardzo byłyby rozbudowane.

Wyjaśnijmy, jak będzie działać API do uwierzytelnienia użytkownika oraz zarządzania. Jeżeli użytkownikowi powiedzie się zalogowanie, nasza aplikacja zwróci mu nowo wygenerowany token dostępu oraz token do odnawiania pierwszego accessToken & refreshToken. Za każdym razem, kiedy użytkownik będzie wysłać żądanie chronione, czyli takie, do którego potrzebuje uwierzytelnienia, będzie musiał w zapytaniu przekazać accessToken. Serwer zweryfikuje, czy token jest ważny i prawidłowy oraz zwróci odpowiedź.

Co zrobić w sytuacji, gdy token zostanie wygenerowany, a użytkownik zostanie usunięty lub jego prawa dostępu zostaną zmienione? Tu z pomocą przychodzi nam refreshToken. Tokeny JWT mają ustawiony czas ważności. W przypadku naszej aplikacji, token dostępu powinien mieć krótki czas ważności, natomiast token odnawiający o wiele dłuższy. Dzięki temu, będziemy w stanie cyklicznie odnawiać token i zapisywać go po stronie klienta.

Aby to lepiej zrozumieć, warto zapoznać się z poniższym schematem:

Diagram of Client-Server login & JWT token validation stages.
  1. Klient wysyła email oraz hasło na serwer. 
  2. Serwer weryfikuje dane użytkownika z tymi, które są w bazie MongoDB. 
  3. Jeśli uwierzytelnienie powiedzie się, serwer zwraca wygenerowane tokeny. AccessToken z krótkim czasem ważności oraz refreshToken, którego ważność powinna być dłuższa.
  4. Klient przechowuje token w pamięci lokalnej np. LocalStorage
  5. Przy wykonaniu żądania chronionego klient dostarcza accessToken w żądaniach zapytania Authorization: Bearer < accessToken >
  6. Serwer po otrzymaniu JWT sprawdza poprawność oraz zwraca odpowiedź, ewentualnie błąd, jeżeli weryfikacja nie powiedzie się. 
  7. W tym samym czasie cyklicznie w tle odnawiamy token przy użyciu refreshTokena, w celu weryfikacji danych i praw użytkownika. 

Do tworzenia tokenów oraz weryfikacji ich w aplikacji, wykorzystamy moduł jsonwebtoken. Kiedy wyjaśnione zostało, jak będzie działać API, możemy przejść do tworzenia naszej aplikacji.

Konfigurowanie aplikacji oraz jej pierwsze uruchomienie

Na początku należy założyć dla całego projektu folder, w którym utworzymy dwa podkatalogi, w taki o to sposób:

application
- frontend // Klient
- backend // API

Następnie przechodzimy do folderu backend i tworzymy plik package.json oraz instalujemy potrzebne paczki:

npm init 
npm i -s express mongoose jsonwebtoken cors bcrypt

Nasz plik package.json powinien wyglądać następująco:

{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "dependencies": {
    "bcrypt": "^3.0.6",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^5.6.11"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" &amp;&amp; exit 1"
  },
  "author": "",
  "license": "ISC"
}

W celu ułatwienia pracy, dodajemy wsparcie do składni ES6. Aby to zrobić, należy zaktualizować plik package.json, żeby wyglądał następująco:


{
  "name": "backend",
  "version": "1.0.0",
  "description": "",
  "main": "src/app.js",
  "scripts": {
    "start": "nodemon --exec babel-node src/app.js",
    "build": "babel src --out-dir dist",
    "serve": "node dist/app.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@babel/polyfill": "^7.4.4",
    "bcrypt": "^3.0.6",
    "body-parser": "^1.19.0",
    "core-js": "^3.2.1",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^5.6.2"
  },
  "devDependencies": {
    "@babel/cli": "^7.5.5",
    "@babel/core": "^7.5.5",
    "@babel/node": "^7.5.5",
    "@babel/plugin-proposal-class-properties": "^7.0.0",
    "@babel/plugin-proposal-export-default-from": "^7.0.0",
    "@babel/plugin-transform-async-to-generator": "^7.5.0",
    "@babel/preset-env": "^7.5.5",
    "nodemon": "^1.19.1"
  }
}

Taka konfiguracja pliku package.json pozwoli nam w łatwy sposób pracować nad interfejsami API. Jeżeli uznamy, że jest już gotowy i działa, wystarczy zbudować naszą paczkę i wrzucić na serwer, gdzie ją uruchomimy. Jednak aby wszystko poprawnie działało, trzeba stworzyć plik wyjściowy, którym będzie app.js w katalogu src., znajdujący się w folderze backend.

Tak powinien wyglądać plik src/app.js:

import express from 'express' 
import bodyParser from 'body-parser' 
import cors from 'cors' 
 
 // Initialize app 
const app = express(); 
 
app.use(cors()); 
app.use(bodyParser.json()) 
app.use(bodyParser.urlencoded({extended: false})); 
app.get('/', (req, res) =&gt; {
  res.json({app: 'Run app auth'}); 
}); 
 
// Start app
app.listen(4200, () =&gt; {
  console.log('Listen port ' + 4200);
})

Na pierwszy rzut oka, może wydawać się to skomplikowane, jednakże nie dzieje się tu nic nadzwyczajnego. Początkowo importujemy zależności takie jak Express, body-parser oraz Cors. W następnej linii inicjalizujemy Express.js, natomiast kolejne wywołania konfigurują naszą aplikację.

CORS [Cross-Origin Resource Sharing] to mechanizm, który dzięki użyciu dodatkowych nagłówków w zapytaniu, pozwala na pobranie zasobów z innych źródeł domenowych. Domyślnie przeglądarki blokują próby pobrania zasobów, które znajdują się pod inną domeną lub subdomeną. Jest to jeden z podstawowych mechanizmów zapewnienia bezpieczeństwa użytkownika w przeglądarce i nazywa się Single-Origin Policy. Szczególnie wrażliwe na tę politykę są aplikacje typu SPA, które z pomocą zapytań XHR pobierają zasoby najczęściej z innego źródła. W opisywanym przykładzie również występuje taka sytuacja. 

Trzeba również dodać odpowiedź  ‘Run app auth’, gdy na stronie głównej zostanie wysłane żądanie GET. Ostatnia linia definiuje nasłuchiwanie połączeń na określonym hoście i porcie. W naszym przypadku port został ustawiony na 4200.

Aby zakończyć proces tworzenia początkowej aplikacji oraz konfiguracji, należy dodać jeszcze w folderze backend plik .babelrc. Pamiętasz jak wcześniej wspomniano, żeby dodać do pliku package.json kilka paczek @babel? Babel jak zestawem narzędzi, który wykorzystujemy do konwersji kodu ECMAScript 2015+ na kompatybilną wstecz wersję JavaScriptu w obecnych, jak i starszych przeglądarkach lub środowiskach. Żeby w przyszłości nie napotkać związanych z tym problemów, należy dodać poniższą konfigurację do naszej aplikacji:

Tak powinien wyglądać plik .babelrc:


{
  "presets": [["@babel/env",
    {
      useBuiltIns: "usage",
      "corejs": "3.2.1"
    }
  ]],
  "plugins": [
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-proposal-export-default-from",
  ]
}

Teraz wystarczy zainstalować jeszcze raz zależności oraz uruchomić aplikację w trybie developerskim. Następnie należy otworzyć przeglądarkę i sprawdzić, czy działa pod adresem localhost:4200: 

npm i
npm run start

Powinien ukazać się następujący widok:

Tworzenie modelu użytkowników 

Do komunikacji z mongoDB należy użyć mongoose. Jest to nakładka, która zapewnia proste, oparte na schemacie rozwiązanie do modelowania danych aplikacji. Zawiera między innymi wbudowane funkcje rzutowania, sprawdzania poprawności, tworzenia zapytań, czy też logiki biznesowej.

Początkowo trzeba utworzyć schemat, innymi słowy model naszego dokumentu. Każdy ze schematów jest odwzorowany na kolekcję MongoDb i określa kształt dokumentów w tej kolekcji. W naszym folderze musi zostać utworzony src folder models, a w nim plik users.js.

Następnie należy zaimportować moongoose oraz utworzyć schemat naszego użytkownika w następujący sposób:

const mongose = require('mongoose')
const Schema = mongose.Schema

const UserSchema = new Schema({
  name: {
    type: String,
    trim: true,
    required: true,
  },
  email: {
    type: String,
    trim: true,
    required: true,
    unique: true,
  },
  password: {
    type: String,
    trim: true,
    required: true,
    select: false,
  },
  role: {
    type: String,
    trim: true,
    default: 'ADMIN'
  }
},
{
  versionKey: false
}) 
 
// type - typ
// trim - pomija białe znaki
// required - wymagany
// select - pomiń przy zwracaniu obiektu
// default - domyślna wartość
// ustawiłem ponieważ planowo rozwojowo chce dodać role dla użytkowników

Każdy z obiektów ma swoje właściwości, które po krótce zostały opisane powyżej. Jednakże brakuje tu jeszcze szyfrowania hasła w bazie danych, w celu zwiększenia bezpieczeństwa. Do tego potrzeba bcrypt

Bcrypt to funkcja skrótu kryptograficznego, która powstała w oparciu o szyfr blokowy Blowfish. Została stworzona głównie w celu hasowania haseł statycznych, a nie jak inne znane funkcje do hashowania dowolnych danych binarnych. Dzięki zastosowaniu soli jest odporna na ataki typu ‚rainbow table’. Pozwala sterować jego złożonością obliczeniową poprzez zmianę ilości rund w procesie hasowania (tzw. work factor). Daje nam to dużą elastyczność przeciwko atakom w przyszłości.

Wystarczy poniżej schematu użyć szyfrowania hasła przed zapisem w kolekcji z użytkownikami. Tak powinien wyglądać plik scr/models/users.js

const mongose = require('mongoose') 
const bcrypt = require('bcrypt') 

const saltRounds = 10 
const Schema = mongose.Schema 

const UserSchema = new Schema({ 
  name: { 
    type: String, 
    trim: true, 
    required: true, 
  }, 
  email: { 
    type: String, 
    trim: true, 
    required: true, 
    unique: true, 
  }, 
  password: { 
    type: String, 
    trim: true, 
    required: true, 
    select: false, 
  }, 
  role: { 
    type: String, 
    trim: true, 
    default: 'ADMIN' 
  } 
}, 
{ 
  versionKey: false 
}) 

UserSchema.pre('save', function (next) { 
  this.password = bcrypt.hashSync(this.password, saltRounds) 
  next() 
})

module.exports = mongose.model('Users', UserSchema)

Teraz na podstawie tego modelu, będziemy w stanie tworzyć użytkownika. Pisząc dokładniej, użytkownik jaki zostanie utworzony będzie zawarty w takim modelu. Jednak aby móc wykorzystać ów model, należy utworzyć dla naszego API interfejsy, które będą miały przypisane do siebie konkretne akcje. Np. tworzenie użytkownika na podstawie określonego modelu, wyświetlanie listy użytkowników lub odnawianie tokenów.

Routing API 

Przejdźmy do stworzenia pierwszej trasy naszego API. W folderze backend trzeba dodać nowy folder controllers, a w nim plik auth.js. Zostaną w nim stworzone wszystkie funkcjonalności związane z naszymi trasami do uwierzytelnienia oraz generowania tokenów. Tak powinien wyglądać gotowy plik:

import UserSchema from '../models/users' 
import bcrypt from 'bcrypt' 
import jwt from 'jsonwebtoken' 
import { 
    TOKEN_SECRET_JWT 
} from '../config' 

 
// Validate email address 
function validateEmailAccessibility(email) { 
    return UserSchema.findOne({ 
        email: email 
    }).then((result) =&gt; { 
        return !result; 
    }); 
} 


// Generate token 
const generateTokens = (req, user) =&gt; { 
    const ACCESS_TOKEN = jwt.sign({ 
            sub: user._id, 
            rol: user.role, 
            type: 'ACCESS_TOKEN' 
        }, 
        TOKEN_SECRET_JWT, { 
            expiresIn: 120 
        }); 
    const REFRESH_TOKEN = jwt.sign({ 
            sub: user._id, 
            rol: user.role, 
            type: 'REFRESH_TOKEN' 
        }, 
        TOKEN_SECRET_JWT, { 
            expiresIn: 480 
        }); 
    return { 
        accessToken: ACCESS_TOKEN, 
        refreshToken: REFRESH_TOKEN 
    } 
} 


// Controller create user 
exports.createUser = (req, res, next) =&gt; { 
    validateEmailAccessibility(req.body.email).then((valid) =&gt; { 
        if (valid) { 
            UserSchema.create({ 
                name: req.body.name, 
                email: req.body.email, 
                password: req.body.password 
            }, (error, result) =&gt; { 
                if (error) 
                    next(error); 
                else 
                    res.json({ 
                        message: 'The user was created' 
                    }) 
            }); 
        } else { 
            res.status(409).send({ 
                message: "The request could not be completed due to a conflict" 
            }) 
        } 
    }); 
}; 


// Controller login user 
exports.loginUser = (req, res, next) =&gt; { 
    UserSchema.findOne({ 
        email: req.body.email 
    }, (err, user) =&gt; { 
        if (err || !user) { 
            res.status(401).send({ 
                message: "Unauthorized" 
            }) 
            next(err) 
        } else { 
            if (bcrypt.compareSync(req.body.password, user.password)) { 
                res.json(generateTokens(req, user)); 
            } else { 
                res.status(401).send({ 
                    message: "Invalid email/password" 
                }) 
            } 
        } 
    }).select('password') 
}; 
 

// Verify accessToken 
exports.accessTokenVerify = (req, res, next) =&gt; { 
    if (!req.headers.authorization) { 
        return res.status(401).send({ 
            error: 'Token is missing' 
        }); 
    } 
    const BEARER = 'Bearer' 
    const AUTHORIZATION_TOKEN = req.headers.authorization.split(' ') 
    if (AUTHORIZATION_TOKEN[0] !== BEARER) { 
        return res.status(401).send({ 
            error: "Token is not complete" 
        }) 
    } 
    jwt.verify(AUTHORIZATION_TOKEN[1], TOKEN_SECRET_JWT, function(err) { 
        if (err) { 
            return res.status(401).send({ 
                error: "Token is invalid" 
            }); 
        } 
        next(); 
    }); 
}; 


// Verify refreshToken 
exports.refreshTokenVerify = (req, res, next) =&gt; { 
    if (!req.body.refreshToken) { 
        res.status(401).send({ 
            message: "Token refresh is missing" 
        }) 
    } 
    const BEARER = 'Bearer' 
    const REFRESH_TOKEN = req.body.refreshToken.split(' ') 
    if (REFRESH_TOKEN[0] !== BEARER) { 
        return res.status(401).send({ 
            error: "Token is not complete" 
        }) 
    } 
    jwt.verify(REFRESH_TOKEN[1], TOKEN_SECRET_JWT, function(err, payload) { 
        if (err) { 
            return res.status(401).send({ 
                error: "Token refresh is invalid" 
            }); 
        } 
        UserSchema.findById(payload.sub, function(err, person) { 
            if (!person) { 
                return res.status(401).send({ 
                    error: 'Person not found' 
                }); 
            } 
            return res.json(generateTokens(req, person)); 
        }); 
    }); 
}

Początkowo importujemy wszystkie zależności, czyli schemat naszego użytkownika, który będzie wykorzystywany do jego tworzenia oraz bcrypt, który będzie służył do weryfikacji hasła przekazanego przez użytkownika z tym zakodowanym w bazie danych. Została również zaimportowana paczka jsonwebtoken, która służy do generowania oraz walidacji naszych tokenów, a także plik konfiguracyjny config.js, który powinien zostać umieszczony w katalogu backend i wyglądać następująco:

module.exports = { 
  //MONGO CONFIG 
  URI_MONGO: process.env.URI_MONGO || 'mongodb://login:password@localhost:27017/DBName', 
  //PORT APP CONFIG 
  PORT_LISTEN: process.env.PORT_LISTEN || 4200, 
  //JWT CONFIG 
  TOKEN_SECRET_JWT: process.env.TOKEN_SECRET_JWT || 'jWt9982_s!tokenSecreTqQrtw' 
}

W tym pliku zostały zawarte najważniejsze zmienne: 

  • URI_MONGO – dane połączenia z bazą mongoDB (nazwa użytkownika, hasło oraz nazwa bazy danych), 
  • PORT_LISTEN – port, na którym będzie wystawiona nasza aplikacja, 
  • TOKEN_SECRET_JWT – tajny klucz do kodowania tokenów JWT.

Wróćmy jeszcze do naszego pliku controllers/auth.js, który został przedstawiony poniżej:

// Validate email address 
function validateEmailAccessibility(email){ 
  return UserSchema.findOne({email: email}).then((result) =&gt; { 
    return !result; 
  }); 
}

Funkcja ta weryfikuje, czy użytkownik o podanym adresie email istnieje w naszej kolekcji mongoDB:

// Generate token 
const generateTokens = (req, user) =&gt; { 
    const ACCESS_TOKEN = jwt.sign({ 
            sub: user._id, 
            rol: user.role, 
            type: 'ACCESS_TOKEN' 
        }, 
        TOKEN_SECRET_JWT, { 
            expiresIn: 120 
        }); 
    const REFRESH_TOKEN = jwt.sign({ 
            sub: user._id, 
            rol: user.role, 
            type: 'REFRESH_TOKEN' 
        }, 
        TOKEN_SECRET_JWT, { 
            expiresIn: 480 
        }); 
    return { 
        accessToken: ACCESS_TOKEN, 
        refreshToken: REFRESH_TOKEN 
    } 
}

W zmiennych powyżej definiujemy obiekty poszczególnych tokenów JWT, zarówno accessToken jak i refreshToken. Można zauważyć, że ich czas różni się od siebie, mowa tu o wartości expiresIn ustawionej w milisekundach:

// Controller login user 
exports.loginUser = (req, res, next) =&gt; { 
    UserSchema.findOne({ 
        email: req.body.email 
    }, (err, user) =&gt; { 
        if (err || !user) { 
            res.status(401).send({ 
                message: "Unauthorized" 
            }) 
            next(err) 
        } else { 
            if (bcrypt.compareSync(req.body.password, user.password)) { 
                res.json(generateTokens(req, user)); 
            } else { 
                res.status(401).send({ 
                    message: "Invalid email/password" 
                }) 
            } 
        }
    }).select('password') 
};

Interfejs do logowania użytkownika będzie wykonywał akcję loginUser. W pierwszej kolejności należy sprawdzić w bazie, czy użytkownik o podanym adresie email istnieje. Jeżeli tak, sprawdzamy poprawność jego hasła przy użyciu bcrypt, po czym zwracamy nowo wygenerowane tokeny res.json(generateTokeny(req, user)):

// Verify accessToken 
exports.accessTokenVerify = (req, res, next) =&gt; { 
    if (!req.headers.authorization) { 
        return res.status(401).send({ 
            error: 'Token is missing' 
        }); 
    } 
    const BEARER = 'Bearer' 
    const AUTHORIZATION_TOKEN = req.headers.authorization.split(' ') 
    if (AUTHORIZATION_TOKEN[0] !== BEARER) { 
        return res.status(401).send({ 
            error: "Token is not complete" 
        }) 
    } 
    jwt.verify(AUTHORIZATION_TOKEN[1], TOKEN_SECRET_JWT, function(err) { 
        if (err) { 
            return res.status(401).send({ 
                error: "Token is invalid" 
            }); 
        } 
        next(); 
    }); 
}; 
 

// Verify refreshToken 
exports.refreshTokenVerify = (req, res, next) =&gt; { 
    if (!req.body.refreshToken) { 
        res.status(401).send({ 
            message: "Token refresh is missing" 
        }) 
    } 
    const BEARER = 'Bearer' 
    const REFRESH_TOKEN = req.body.refreshToken.split(' ') 
    if (REFRESH_TOKEN[0] !== BEARER) { 
        return res.status(401).send({ 
            error: "Token is not complete" 
        }) 
    } 
    jwt.verify(REFRESH_TOKEN[1], TOKEN_SECRET_JWT, function(err, payload) { 
        if (err) { 
            return res.status(401).send({ 
                error: "Token refresh is invalid" 
            }); 
        } 
        UserSchema.findById(payload.sub, function(err, person) { 
            if (!person) { 
                return res.status(401).send({ 
                    error: 'Person not found' 
                }); 
            } 
            return res.json(generateTokens(req, person)); 
        }); 
    }); 
}

Ta funkcjonalność będzie służyła do weryfikacji odpowiedniego tokena. Należy pamiętać, że zarówno jeden jak i drugi token powinien mieć dodany początek Bearer <jwt_token>. Jeżeli token zostanie prawidłowo przekazany, funkcja verify przy użyciu klucza tajnego zweryfikuje jego poprawność:

// Controller create user 
exports.createUser = (req, res, next) =&gt; { 
  validateEmailAccessibility(req.body.email).then((valid) =&gt; { 
    if (valid) { 
      UserSchema.create({ 
        name: req.body.name, 
        email: req.body.email, 
        password: req.body.password }, (error, result) =&gt; { 
          if (error) 
            next(error); 
          else 
            res.json({ 
              message: 'The user was created'}) 
      }); 
    } else { 
      res.status(409).send({message: "The request could not be completed due to a conflict"}) 
    } 
  }); 
};

Aby móc tworzyć nowych użytkowników, należy dodać również funkcję createUser. Będzie ona służyć kreowaniu nowego użytkownika na podstawie wcześniej zbudowanego schematu.

Teraz przyszedł czas na stworzenie routingu dla API. W związku z tym należy utworzyć w folderze src plik routes.js

import express from 'express' 
import authController from './controllers/auth' 

const router = express.Router(); 

router.post('/login', authController.loginUser); 
router.post('/refresh', authController.refreshTokenVerify); 

// secure router 
router.post('/register', authController.accessTokenVerify, authController.createUser); 

module.exports = router;

Następnie trzeba zaimportować kontroler z funkcjami do walidacji, logowania, odnawiania tokenu oraz tworzenia użytkownika. Należy zwrócić uwagę, że interfejs /register  jest chroniony. Nie uda się go wykonać, jeżeli nie będziemy poprawnie uwierzytelnieni, a nasz token nie będzie ważny i prawidłowy. 

Na koniec musimy jeszcze stworzyć interfejs do pobierania listy użytkowników w systemie. Należy zatem dodać do folderu controllers plik users.js

import UserSchema from '../models/users' 

// Controller get users list 
exports.getUserList = (req, res, next) =&gt; { 
  UserSchema.find({}, {}, (err, users) =&gt; { 
    if (err || !users) { 
      res.status(401).send({message: "Unauthorized"}) 
      next(err) 
    } else { 
      res.json({status: "success", users: users}); 
    } 
  }) 
}

oraz zaktualizować plik routes.js:

import express from 'express' 
import authController from './controllers/auth' 
import usersController from './controllers/users' 

const router = express.Router(); 

router.post('/login', authController.loginUser); 
router.post('/refresh', authController.refreshTokenVerify); 

// secure router 
router.get('/users', authController.accessTokenVerify, usersController.getUserList); 
router.post('/register', authController.accessTokenVerify, authController.createUser); 

module.exports = router;

Dzięki temu, jeżeli zostaniemy prawidłowo uwierzytelnieni, będziemy w stanie pobrać listę istniejących użytkowników. 

Konfiguracja bazy danych

W kolejnym kroku, należy uruchomić bazę danych mongoDB. Możesz skorzystać z chmury np. AWS lub zainstalować ją lokalnie na swoim komputerze na postawie Dokera. W poniższym przykładzie użyto właśnie Dokera do uruchomienia w kontenerze bazy danych. 

Do folderu z aplikacją backendową trzeba dodać plik docker-compose.yml, którego zawartość wygląda następująco: 

version: '3.1' 

services: 
  mongodb: 
    container_name: mongodb 
    image: 'bitnami/mongodb:latest' 
    ports: 
      - 27017:27017 
    environment: 
      - MONGODB_USERNAME=admin // login user 
      - MONGODB_PASSWORD=example // password user 
      - MONGODB_DATABASE=authDB // nazwa naszej bazy 
      - MONGODB_ROOT_PASSWORD=rootExample // hasło użytkownika root

Teraz wystarczy uruchomić docker-compose up-d, a docker pobierze wszystkie zależności i uruchomi naszą bazę danych w tle. Jeżeli chcesz zweryfikować, czy baza danych uruchomiła się, użyj docker ps –a. Powinieneś zobaczyć informację o uruchomionym kontenerze.

docker ps -a command database check

Połączenie z bazą danych oraz użycie routingu

Teraz można wrócić do pliku app.js, użyć routingu oraz zdefiniować połączenie z naszą bazą danych. Poniżej przedstawiono, jak powinien wyglądać zaktualizowany plik, z wykorzystaniem zmiennych z pliku config.js oraz wszystkich dotychczasowych kroków: 

import express from 'express' 
import bodyParser from 'body-parser' 
import cors from 'cors' 
import mongoose from 'mongoose' 
import routes from './routes' 
import config from './config' 
import { initializeData } from './seed/user-seeder' 

 
// Initialize app 
const app = express(); 


app.use(cors()); 
app.use(bodyParser.json()) 
app.use(bodyParser.urlencoded({extended: false})); 
app.get('/', (req, res) =&gt; { 
  res.json({app: 'Run app auth'}); 
}); 


// Connect to MongoDB 
mongoose.connect(config.URI_MONGO, { 
  useCreateIndex: true, 
  useNewUrlParser: true 
}).catch(err =&gt; console.log('Error: Could not connect to MongoDB.', err)); 

 
mongoose.connection.on('connected', () =&gt; { 
  initializeData() 
  console.log('Initialize user') 
}); 
mongoose.connection.on('error', (err) =&gt; { 
  console.log('Error: Could not connect to MongoDB.', err); 
}); 

 
// Routes app 
app.use('/', routes); 
// Start app 
app.listen(config.PORT_LISTEN, () =&gt; { 
  console.log('Listen port ' + config.PORT_LISTEN); 
})

Należy pamiętać, żeby zaktualizować dane użytkownika, hasło oraz nazwę bazy danych w pliku config.js

//MONGO CONFIG
URI_MONGO: process.env.URI_MONGO || 
'mongodb://admin:example@localhost:27017/authDB'

Na typ etapie zdefiniowane zostało połączenie do bazy danych. Gdy połączenie przebiegnie prawidłowo, wywołuje funkcję initializeDataWarto wspomnieć, że nasz interfejs do tworzenia użytkownika jest chroniony, dlatego pierwszego testowego użytkownika trzeba utworzyć w bazie danych przy połączeniu z nią. W związku z tym w naszym folderze src należy stworzyć folder seed, a w nim plik user-seeder.js. Zostanie tam zdefiniowane tworzenie użytkownika testowego. Tak powinien wyglądać gotowy plik:

import UserSchema from '../models/users' 

async function isUsersExist() { 
  const exec = await UserSchema.find().exec() 
  return exec.length &gt; 0 
} 

// Initialize first user 
export const initializeData = async () =&gt; { 
  if(!await isUsersExist()) { 
    const user = [ 
      new UserSchema({ 
        role: "ADMIN", 
        name: "admin", 
        email: "admin@admin.com", 
        password: "admin" 
      }) 
    ] 
    let done = 0; 
    for (let i = 0; i &lt; user.length; i++) { 
      user[i].save((err, result) =&gt; { 
        done++; 
      }) 
    } 
  } 
}

Przyszedł czas na uruchomienie naszego API. Na początku należy upewnić się, czy kontener z bazą danych nadal jest otwarty. Po uruchomieniu komendy w terminalu npm run start powinien pojawić się taki widok:

'npm run start terminal' view

Trzeba jeszcze sprawdzić, czy w bazie danych został utworzony odpowiedni użytkownik:

database view db.getCollection

Teraz można upewnić się, czy nasze API faktycznie działa. W opisanym przypadku został użyty postman do sprawdzenia zapytań.

Zapytanie /login – w odpowiedzi powinniśmy otrzymać tokeny JWT:

Postman POST check on http://localhost:4200/login

Następnie należy sprawdzić zapytanie  /users chronione, które powinno zwrócić listę użytkowników. Trzeba pamiętać o przekazaniu accessToken. Na koniec uzyskujemy listę użytkowników:

Postman GET check on http://localhost:4200/login

Zakończenie

Po wykonaniu wszystkich powyższych czynności mamy pewność, że nasze API działa. Teraz można użyć utworzonej aplikacji do uwierzytelnienia użytkowników w wybranym kliencie. Należy zwrócić uwagę, dlaczego stworzone zostały dwa tokeny, a nie standardowo jeden. Wszystko to w celu zachowania kontroli nad dostępem do naszej aplikacji. Częste odświeżanie tokenu pozwala nam weryfikować role i uprawnienia użytkownika oraz kontrolować jego obecność w bazie danych, na wypadek gdyby inny użytkownik usunął go lub zmienił mu dostępy.

Odsyłam do mojego katalogu backend, gdzie znajdziesz wszystkie potrzebne pliki do zbudowania aplikacji na etapie omówionym w tym artykule. W drugiej części przewodnika otrzymasz dostęp do katalogu frontend.

Poniżej opisano, jak powinno wyglądać prawidłowe zachowanie w kliencie: 

  • Po udanym uwierzytelnieniu należy zapisać token w np. localStorage
  • Trzeba odnawiać cyklicznie tokeny, w zależności od ważności accessToken
  • Każde żądanie chronione powinno zawierać nagłówek Bearer <accessToken>.
  • Należy utworzyć middelware, który będzie sprawdzał, czy istnieje możliwość wejścia na odpowiedni adres w danym kliencie.
  • Jeżeli odnowienie tokena lub uwierzytelnienie nie powiedzie się, trzeba zabronić dostępu oraz wyczyścić localStorage

Jeżeli napotkasz problemy z implementacją API w kliencie, w drugiej części poradnika dowiesz się, jak wykorzystać nasze API w prostym kliencie opartym na frameworku Vue.js.


Chcesz dowiedzieć się więcej? Przeczytaj drugą część artykułu.

Czujesz inspirację? Sprawdź, kogo szukamy i dołącz do naszego zespołu!

Nasi eksperci
/ Dzielą się wiedzą

23.04.2024

AI w marketingu / Przegląd możliwości

AI

„W dynamicznie rozwijającym się świecie biznesu, optymalizacja marketingu odgrywa coraz ważniejszą rolę”. Tak zapewne zaczynałby się kolejny artykuł wygenerowany przez sztuczną inteligencję. Bez obaw! Choć jesteśmy fanami AI, to poniższy tekst został napisany przez człowieka. Przygotowaliśmy dla Ciebie przegląd najważniejszych...

PIM in Marketplace Platform
16.04.2024

Rola systemów PIM na platformach marketplace

E-Commerce

Dobrej jakości, kompletne informacje produktowe pozwalają sprzedawcom w odpowiedni sposób zaprezentować asortyment, a klientom znaleźć dokładnie to, czego szukają. Najpopularniejsze platformy marketplace działają niemalże jak wyszukiwarki, pozwalając konsumentom na zaawansowane filtrowanie ofert za pomocą wielu słów kluczowych i różnych...

11.04.2024

Nowy model cenowy MuleSoft / Niższy próg wejścia 

Integracja systemów

MuleSoft to lider integracji aplikacji i systemów, ułatwiający firmom tworzenie złożonych rozwiązań informatycznych, czerpiącym ze wszystkich dostępnych zasobów informatycznych. Producent oferuje także reużywalne API i konfiguracje umożliwiające proste łączenie zróżnicowanych systemów. Ostatnio MuleSoft wprowadził znaczące zmiany w...

Ekspercka wiedza
dla Twojego biznesu

Jak widać, przez lata zdobyliśmy ogromną wiedzę - i uwielbiamy się nią dzielić! Porozmawiajmy o tym, jak możemy Ci pomóc.

Napisz do nas

<dialogue.opened>