unity wiedza

Czas czytania: 23 minut

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 ytkownik jest tym, za którego się podaje. W kolejnym etapie dochodzimy do autoryzacji 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 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.

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 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) – Zawiera 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. Należy pamiętać, że 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 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 ich zarządzania. Jeżeli użytkownikowi powiedzie się zalogowanie, nasza aplikacja zwróci mu nowo wygenerowany token dostępu oraz token do odnawiania pierwszegoaccessToken & refreshToken. Za każdym razem, kiedy użytkownik będzie wysł żą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ć sytuacji, gdy token zostanie wygenerowany, a użytkownik zostanie usunięty lub jego prawa dostępu zostaną zmienione? Tu z pomocą przychodzi nam refreshTokenTokeny 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:

  • Klient wysyłemail oraz hasło na serwer. 
  • Serwer weryfikuje dane użytkownika z tymi, które są w bazie MongoDB. 
  • Jeśli uwierzytelnienie powiedzie się, serwer zwraca wygenerowane tokenyAccessToken z krótkim czasem ważności oraz refreshToken, którego ważność powinna być dłuższa.
  • Klient przechowuje token w pamięci lokalnej np. LocalStorage. 
  • Przy wykonaniu żądania chronionego klient dostarcza accessToken w żądaniach zapytania Authorization: Bearer < accessToken >. 
  • Serwer po otrzymaniu JWT sprawdza poprawność oraz zwraca odpowiedź, ewentualnie błąd, jeżeli weryfikacja nie powiedzie się. 
  • 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ł jsonwebtokenKiedy 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ż 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 folderubackend i tworzymy plikpackage.jsonoraz instalujemy potrzebne paczki: 

npm init 
npm i -s express mongoose jsonwebtoken cors bcrypt

Nasz plikpackage.jsonpowinien 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\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

W celu ułatwienia pracydodajemy 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ć pliksrc/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) => {
  res.json({app: 'Run app auth'}); 
}); 

// Start app
app.listen(4200, () => {
  console.log('Listen port ' + 4200);
})

Na pierwszy rzut oka, może wydawać się to skomplikowanejednakż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 adresemlocalhost: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 tej kolekcji. W naszym folderze musi zostać utworzonysrc folder models,a w nim plikusers.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łciwościktó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ż szyfrowania hasła przed zapisem w kolekcji z 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) => { 
        return !result; 
    }); 
} 


// Generate token 
const generateTokens = (req, user) => { 
    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) => { 
    validateEmailAccessibility(req.body.email).then((valid) => { 
        if (valid) { 
            UserSchema.create({ 
                name: req.body.name, 
                email: req.body.email, 
                password: req.body.password 
            }, (error, result) => { 
                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) => { 
    UserSchema.findOne({ 
        email: req.body.email 
    }, (err, user) => { 
        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) => { 
    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) => { 
    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.jsktóry został przedstawiony poniżej:

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

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

// Generate token 
const generateTokens = (req, user) => { 
    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) => { 
    UserSchema.findOne({ 
        email: req.body.email 
    }, (err, user) => { 
        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) => { 
    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) => { 
    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) => { 
  validateEmailAccessibility(req.body.email).then((valid) => { 
    if (valid) { 
      UserSchema.create({ 
        name: req.body.name, 
        email: req.body.email, 
        password: req.body.password }, (error, result) => { 
          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 systemie. Należy zatem dod do folderucontrollers plikusers.js: 

import UserSchema from '../models/users' 

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

oraz zaktualizować plikroutes.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 postawieDokera.W poniższym przykładzie użyto właśnie Dokera do uruchomienia w kontenerze bazy danych. 

Do folderu z aplikacją backendową trzeba dodać plikdocker-compose.ymlktó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łsię, użyjdocker ps –a. Powinieneś zobaczyć informację o uruchomionym kontenerze.

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

Teraz można wrócić do plikuapp.js, 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) => { 
  res.json({app: 'Run app auth'}); 
}); 


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

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

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

Należy pamiętać, żeby zaktualizować dane użytkownika, hasło oraz nazwę bazy danych w plikuconfig.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ęinitializeDataNależy pamiętać, ż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 folderzesrcnależy stworzyć folderseed,a w nim plikuser-seeder.jsZostanie 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 > 0 
} 

// Initialize first user 
export const initializeData = async () => { 
  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 < user.length; i++) { 
      user[i].save((err, result) => { 
        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 terminalunpm run start powinien pojawić się taki widok:

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

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

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

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:

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 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 zapis token w np. localStorage. 
  • Trzeba odnawiać cyklicznie tokeny, w zależności od ważnościaccessToken. 
  • Każde żądanie chronione powinno zawierać nagłówekBearer <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śclocalStorage. 

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 framework’u Vue.js. 

Wyrażam zgodę na przetwarzanie danych osobowych na zasadach określonych w polityce prywatności. Jeśli nie wyrażasz zgody na wykorzystywanie cookies we wskazanych w niej celach, w tym do profilowania, prosimy o wyłącznie cookies w przeglądarce lub opuszczenie serwisu. więcej

Akceptuj