Moduł 4 - Zajęcia 7 - Uwierzytelnienie i autoryzacja
1.1 Uwierzytelnienie przy pomocy JSON Web Token (JWT)
Teraz przeanalizujemy uwierzytelnienie zapytań przy pomocy JWT. Tokeny te generowane są na backendzie i wykorzystywane przez API oraz przez aplikacje webowe w dowolnym współczesnym frameworku typu React, Angular, Vue.js.
Aplikacja webowa będzie więc wysyłać token jwt, dołączony do każdego zapytania, najczęściej umieszczony w nagłówku Authorization
Przeanalizujmy dla naszego API trzy routes:
- /registration — rejestracja nowego użytkownika;
- /login — dla poprawnych danych logowania zwróci jwt
- /list, — dostępny tylko dla użytkowników autoryzujących się przy pomocy tokena.
Końcowy wynik będzie wygląda w następujący sposób: link
1.2 Czym są web-tokeny JSON?
JSON Web Token (JWT) to zasadniczo string (w formacie bezpiecznym dla URL, to znaczy nie zawiera znaków specjalnych które należałoby zakodować), zawierający zakodowany obiekt JSON. JWT to otwarty, szeroko wykorzystywany standard, opisany w standardzie RFC 7519, który zawiera szczegóły implementacji, dotyczące zwłaszcza tego, w jaki sposób JWT ma implementować funkcje generowania i sprawdzania poprawności tokena.
Popatrz na przykład tokenu JWT ze strony https://jwt.io/ :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Zwróć uwagę, że token podzielony jest na trzy części, które rozdzielone są kropką .
header - zawiera informację, między innymi o tym jaki algorytm hashowania został zastosowany przy tworzeniu JWT, dzięki czemu wiadomo jak prawidłowo rozszyfrować i sprawdzić autentyczność tokena.
{
"alg": "HS256",
"typ": "JWT"
}
payload - Część ta zawiera dane, które chcesz przechowywać przy pomocy JWT. Zwróć uwagę, że payload jest niezabezpieczony i może być rozszyfrowany bez prywatnego klucza, dane zakodowane są po prostu w Base64 (popularny "dwustronny" sposób szyfrowania informacji). JWT nie są przeznaczone do wysyłania danych poufnych, takich jak hasła, numery kart kredytowych itp.
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
signature - podpis, otrzymany poprzez zaszyfrowanie header i payload, przy użyciu sekretnego klucza prywatnego oraz określonego w headerze algorytmu hashowania.
W ten sposób za każdym razem, gdy tworzony jest token na bazie tych samych informacji,
- header zostanie taki sam (jeżeli nie zmienisz algorytmu hashowania);
- payload również będzie taki sam;
- signature natomiast będzie zawierać zaszyfrowane dane, na podstawie algorytmu hashowania przy użyciu naszego tajnego klucza - może się więc zmieniać (później zobaczymy dlaczego)
1.3 Kodowanie i dekodowanie JWT
Do tworzenia jwt, będziemy wykorzystywać pakiet npm pod nazwą [jsonwebtoken], który pozwala szyfrować i deszyfrować nasze tokeny. Dla przykładu przeanalizujemy następujący kod:
const jwt = require('jsonwebtoken');
const payload = { id: 123456, username: 'Larson' };
const secret = 'secret word';
const token = jwt.sign(payload, secret);
console.log(token);
Zobaczmy kolejne kroki. Aplikacja zaczyna się od dołączenia modułu jsonwebtoken:
const jwt = require('jsonwebtoken');
Następnie tworzymy payload - czyli dane które chcemy zapisać w tokenie.
const payload = { id: 123456, username: 'Larson' };
Utworzyliśmy obiekt, który zawiera właściwość id z wartością 123456 i właściwość username o wartości 'Larson'. Token powinien być zaszyfrowany (i weryfikowany) przy użyciu tajnego klucza. Tworzymy string, który będzie wykorzystywany do podpisania tokenu, tak, aby nie można było go sfałszować. Tylko serwer powinien mieć dostęp do sekretu.
UWAGA: nigdy nie udostępniaj sekretu, nie przechowuj go w aplikacji frontendowej, nie trzymaj go w repozytorium - traktuj go jak hasło do bazy danych.
const secret = 'secret word';
Kolejnym krokiem będzie utworzenie naszego JWT. Robimy to poprzez wywołanie funkcji sign z modułu jsonwebtoken.
const token = jwt.sign(payload, secret);
Funkcja ta przyjmuje payload i nasz tajny klucz. Rezultatem działania tej funkcji jest token, który zawiera nasz zakodowany header, payload i signature.
W ostatniej instrukcji wypisujemy nasz token do konsoli. Uruchamiając naszą aplikację,
$ node app.js
otrzymamy mniej więcej taką wartość:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJ1c2VybmFtZSI6IkxhcnNvbiIsImlhdCI6MTYwMjQ2NDkxNX0.73IZBwNckdPPr0j813BBfFMU0ooitR5UrmaQwLaK6AI
To token podobny do tego, który wzięliśmy ze strony https://jwt.io/ . Zawiera on te same trzy części (header, payload i signature). Jeżeli wstawimy nasz token do sekcji Encoded to zobaczymy jego zawartość
Dodajmy do pliku app.js dekodowanie tokenu i również wypiszmy informacje o nim do konsoli:
const decode = jwt.decode(token);
console.log(decode);
Teraz, gdy uruchamiamy aplikację, otrzymujemy mniej więcej taki wynik:
$ node app.js
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJ1c2VybmFtZSI6IkxhcnNvbiIsImlhdCI6MTYwMjQ2NTQ5NH0.UOZww-C3SSZJz_I4o0vZNAAkTwFQFPM8cn1HQwnxRU4
{ id: 123456, username: 'Larson', iat: 1602465494 }
Widać, że token został z sukcesem zdekodowany i zawiera te same dane co wcześniej, wraz z dodatkowym atrybutem:
iat - Issued At - czyli timestamp momentu w który został wygenerowany dany token - dlatego też nasze tokeny będą nieco innego w zależności od tego kiedy zostały utworzone.
Do sprawdzenia autentyczności tokenu należy wykorzystać metodę verify.
const verify = jwt.verify(token, secret);
console.log(verify);
Wartość zmiennej verify jest taka sama, jak w decode, ale jeśli token będzie podrobiony, to zostanie wyrzucony błąd 'JsonWebTokenError: invalid signature'.
1.4 Autoryzacja przy pomocy JWT
Wróćmy teraz do naszej aplikacji po tym, jak dowiedzieliśmy się, jak pracować z JWT.
1.5 Strategia JWT
Zacznijmy od konfiguracji strategii JWT. Odpowiada za nią moduł passport-jwt:
const passport = require('passport');
const passportJWT = require('passport-jwt');
const User = require('../schemas/user');
require('dotenv').config();
const secret = process.env.SECRET;
const ExtractJWT = passportJWT.ExtractJwt;
const Strategy = passportJWT.Strategy;
const params = {
secretOrKey: secret,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
};
// JWT Strategy
passport.use(
new Strategy(params, function (payload, done) {
User.find({ _id: payload.id })
.then(([user]) => {
if (!user) {
return done(new Error('User not found'));
}
return done(null, user);
})
.catch(err => done(err));
}),
);
Krótko powiedzmy, czym jest strategia w tym kontekście. To funkcja oprogramowania pośredniczącego, przez którą przechodzą zapytania, zanim znajdą się w funkcji opracowywania trasy. Jeżeli strategia uwierzytelniania zwraca błąd w callbacku done, oznacza to, że program opracowywania trasy nie będzie wywołany i wysłana zostanie odpowiedź 401 Unauthorized (Błąd autoryzacji).
Strategia JWT jest skonfigurowana tak, aby odczytać token JWT z nagłówka HTTP Authorization (autoryzacja) - dla każdego wprowadzanego zapytania.
Zamiast ExtractJWT.fromAuthHeaderAsBearerToken można określić inne metody odczytywania tokenów lub nawet napisać swoją własną. Pełną listę można znaleźć w dokumentacji paczki passport-jwt.
Jeśli skonfigurujecie passport tak jak w przykładzie, to będziecie oczekiwać nagłówka w następującej postaci:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6IjVmMWYxODhiYzdmMGRiMmJjNDVhZTdkNiIsImlhdCI6MTU5NTg3NDA5MiwiZXhwIjoxNTk1ODc3NjkyfQ.
SJuXhjiNrhsZ-9Ikdw7wdkttn-KcLTztd_Rk3kf4elA
W wartości nagłówka, po słowie kluczowym Bearer następuje spacja, a później nasz token JWT.
Właściwość secretOrKey to tajny klucz, którym będą podpisane nasze tokeny. Dla bezpieczeństwa przechowujemy tajny klucz tylko w zmiennych środowiskowych, aby nikt nie znalazł ich w kodzie nawet jeśli da radę włamać się do naszego repozytorium.
W naszej strategii otrzymujemy ładunek payload, w którym znajduje się id użytkownika. Następnie zwracamy się do bazy danych i próbujemy znaleźć użytkownika o takim id i albo zwracamy obiekt użytkownika, albo błąd, jeśli użytkownik nie został znaleziony.
W głównym pliku naszej aplikacji dołączamy następującą konfigurację
...
require('./config/config-passport')
app.use('/api', routerApi)
...
1.6 Route - rejestracja
Tworzymy użytkownika z unikalnym email, jeżeli użytkownik z podanym email już istnieje, zwracamy status 409 Conflict. Jeżeli nie, tworzymy go i zapisujemy w bazie danych:
router.post('/registration', async (req, res, next) => {
const { username, email, password } = req.body;
const user = await User.findOne({ email }).lean();
if (user) {
return res.status(409).json({
status: 'error',
code: 409,
message: 'Email is already in use',
data: 'Conflict',
});
}
try {
const newUser = new User({ username, email });
newUser.setPassword(password);
await newUser.save();
res.status(201).json({
status: 'success',
code: 201,
data: {
message: 'Registration successful',
},
});
} catch (error) {
next(error);
}
});
1.7 Logowanie - otrzymanie tokenu do autoryzacji
Jeżeli dla użytkownika zgadza się kombinacja email i passsword, generujemy dla niego JWT token.
Tworzymy ładunek payload, w którym umieszczamy id użytkownika i jego username.
Następnie tworzymy token przy pomocy metody jwt.sign. Pierwszy parametr to wyżej wspomniane dane, drugi to klucz-sekret, który wykorzystujemy w strategii autoryzacji passport-jwt, a trzeci parametr to określenie "żywotności" naszego tokenu JWT, czyli określamy po jakim czasie straci on swoją ważność (informacja ta zostanie automatycznie dołączona do payload).
Zdefiniowaliśmy żywotność na jedną godzinę (1h), moglibyśmy też użyć zapisów takich jak:
- 2 dni - 2d,
- 1 tydzień - 1w
- 5 miesięcy - 5m
- 1 rok - 1y
lub po prostu podać (w milisekundach) wybraną wartość
W zasadzie nigdy token nie powinien być "wieczny"
router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !user.validPassword(password)) {
return res.status(400).json({
status: 'error',
code: 400,
message: 'Incorrect login or password',
data: 'Bad request',
});
}
const payload = {
id: user.id,
username: user.username,
};
const token = jwt.sign(payload, secret, { expiresIn: '1h' });
res.json({
status: 'success',
code: 200,
data: {
token,
},
});
});
1.8 Route dostępny jedynie z tokenem
Tutaj wszystko jest proste - program opracowywania tras zadziała tylko, jeśli wysłany z zapytaniem token jest prawidłowy, a za sprawdzenie jego poprawności odpowiada middleware auth:
router.get('/list', auth, (req, res, next) => {
const { username } = req.user;
res.json({
status: 'success',
code: 200,
data: {
message: `Authorization was successful: ${username}`,
},
});
});
W funkcji auth przy pomocy passport.authenticate uruchamiamy strategię jwt i sprawdzamy otrzymany token JWT. Jeżeli użytkownik nie został znaleziony lub pojawił się inny błąd, zwracamy 401 'Unauthorized'.
W przypadku gdy użytkownik istnieje a token nie wygasł, umieszczamy dane użytkownika w req.user i przechodzimy do następnego programu pośredniczącego lub programu obsługi przy pomocy wywołania metody next():
const auth = (req, res, next) => {
passport.authenticate('jwt', { session: false }, (err, user) => {
if (!user || err) {
return res.status(401).json({
status: 'error',
code: 401,
message: 'Unauthorized',
data: 'Unauthorized',
});
}
req.user = user;
next();
})(req, res, next);
};
Przy pomocy middleware auth możemy teraz ograniczyć dostęp do dowolnych ścieżek.
Powyższy przykład to właśnie stosunkowo prosta implementacja autoryzacji JWT.