Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Passport.js - system kontroli dostępu

Umiesz już wystawić przepustkę JWT i sprawdzić hasło. Ale w prawdziwym Imperium do bram podchodzą różni goście: jeden ma hasło, drugi przepustkę z pieczęcią, trzeci list polecający od sojusznika z Galii, czwarty klucz do bramy handlowej. Pisząc obsługę każdego z osobna, powielisz tę samą logikę cztery razy - a każdy sposób wpuszczania to nowa okazja do pomyłki.

Rzymianie postawili przy bramie strażnicę z jednym regulaminem i wieloma wartownikami: każdy wartownik zna jeden rodzaj dokumentu, ale wszyscy meldują tak samo. W NestJS tą strażnicą jest Passport.js - biblioteka, w której każdy sposób logowania to osobna strategia, a ekosystem oferuje ich ponad 500 gotowych.

Trzy warstwy strażnicy

Zanim napiszemy linijkę kodu, ustalmy, z czego składa się ta strażnica - bo to jest szkielet całej lekcji i wraca przy każdej strategii:

  1. Passport Module - rejestracja: mówi NestJS, których wartowników w ogóle zatrudniamy.
  2. Strategy - logika weryfikacji: jeden wartownik sprawdzający jeden rodzaj dokumentu.
  3. Guard - aktywacja: wskazuje, którego wartownika wołamy na tej konkretnej bramie.
  4. Dekorator
    @UseGuards()
    - postawienie strażnika przy wejściu do endpointu.

Zapamiętaj tę kolejność od dołu do góry: strategia wie jak sprawdzić, guard wie kogo zawołać, dekorator wie gdzie go postawić.

Strategia lokalna - wartownik od haseł

Zacznijmy od najprostszego wartownika: sprawdza login i hasło.

1@Injectable()
2export class LocalStrategy extends PassportStrategy(Strategy) {
3  constructor(private authService: AuthService) {
4    super({
5      usernameField: 'username',
6      passwordField: 'password',
7    });
8  }
9
10  async validate(username: string, password: string): Promise<any> {
11    const user = await this.authService.validateUser(username, password);
12
13    if (!user) {
14      throw new UnauthorizedException();
15    }
16
17    return user;
18  }
19}

Prześledźmy tę klasę, bo każdy jej element powtórzy się w kolejnych strategiach.

PassportStrategy(Strategy)
to klasa bazowa budowana z importu
Strategy
- tu z pakietu
passport-local
. Zmieniając pakiet, zmieniasz rodzaj wartownika, a reszta szkieletu zostaje.

Wywołanie

super()
w konstruktorze konfiguruje strategię.
usernameField
i
passwordField
mówią, z których pól żądania wziąć dane - i to jest częsty punkt zaczepienia, bo jeśli Twój formularz wysyła
email
zamiast
username
, właśnie tu to zgłaszasz.

Sercem jest

validate()
. Passport wywołuje ją sam, podając wyciągnięte pola, a Twoim zadaniem jest odpowiedzieć: kto to jest. Zwróć uwagę na kontrakt tej metody - to najważniejsze zdanie tej lekcji. Zwrócony obiekt trafia do
request.user
i staje się dostępny w kontrolerze. Gdy dokument jest fałszywy, nie zwracasz
null
ani
false
- rzucasz
UnauthorizedException
, a NestJS zamieni ją na odpowiedź 401.

Guard - wołanie wartownika po imieniu

Strategia sama z siebie nie zadziała. Trzeba ją aktywować, a robi to guard:

1@Injectable()
2export class LocalAuthGuard extends AuthGuard('local') {}

Tak, to całe ciało klasy - puste.

AuthGuard('local')
buduje gotowego guarda, który po nazwie odnajduje zarejestrowaną strategię i uruchamia jej
validate()
. Nazwa
'local'
nie jest przypadkowa: to domyślny identyfikator strategii z pakietu
passport-local
, tak jak
'jwt'
należy do
passport-jwt
, a
'google'
do strategii Google.

Analogicznie wygląda guard dla przepustek JWT, które poznałeś w poprzedniej lekcji:

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {}

Po co w ogóle własna klasa, skoro można napisać

@UseGuards(AuthGuard('local'))
wprost? Z dwóch powodów: nazwa
LocalAuthGuard
czyta się lepiej w kontrolerze, a gdy zechcesz dołożyć własne zachowanie, masz gdzie je wpisać. Właśnie do tego służy metoda
handleRequest
, którą możesz nadpisać:

1@Injectable()
2export class JwtAuthGuard extends AuthGuard('jwt') {
3  handleRequest(err: any, user: any) {
4    if (err) {
5      throw err;
6    }
7
8    if (!user) {
9      throw new UnauthorizedException('Przepustka nieważna lub wygasła');
10    }
11
12    return user;
13  }
14}

Kolejność jest tu logiczna i warto ją zapamiętać: najpierw sprawdzasz błąd, potem obecność użytkownika, dopiero na końcu zwracasz obiekt - a to, co zwrócisz, wyląduje w

request.user
. Nadpisujemy tę metodę głównie po to, by dać czytelny komunikat zamiast gołego 401.

Strategia zewnętrzna - list polecający z Galii

Skoro szkielet się nie zmienia, dołożenie logowania przez Google sprowadza się do podmiany pakietu i danych konfiguracyjnych. Wartownik jest nowy, regulamin ten sam.

1@Injectable()
2export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
3  constructor(private configService: ConfigService) {
4    super({
5      clientID: configService.get('GOOGLE_CLIENT_ID'),
6      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
7      callbackURL: '/auth/google/callback',
8      scope: ['email', 'profile'],
9    });
10  }
11}

Tu

Strategy
pochodzi z pakietu
passport-google-oauth20
.
clientID
i
clientSecret
to dane Twojej aplikacji wydane przez Google - i dlatego czytamy je przez
ConfigService
ze zmiennych środowiskowych, a nie wpisujemy w kod. Sekret w repozytorium to sekret cudzy; to polecam traktować jako regułę bez wyjątków, @name.

callbackURL
to adres, pod który Google odeśle użytkownika po zalogowaniu - Twoja aplikacja musi mieć tam endpoint.
scope
określa, o jakie dane prosisz.

Zwróć uwagę na drugi argument

PassportStrategy(Strategy, 'google')
: to jawnie nadana nazwa, po której guard
AuthGuard('google')
odnajdzie tę strategię. Przy strategii lokalnej mogliśmy go pominąć, bo nazwa domyślna wystarczała.

Postawienie strażnika przy bramie

Ostatni krok to wskazanie, których endpointów strażnik pilnuje:

1@Controller('auth')
2export class AuthController {
3  @UseGuards(LocalAuthGuard)
4  @Post('login')
5  async login(@Request() req) {
6    return this.authService.generateToken(req.user);
7  }
8
9  @UseGuards(JwtAuthGuard)
10  @Get('profile')
11  getProfile(@Request() req) {
12    return req.user;
13  }
14}

Tu domyka się cały łańcuch. Guard uruchomił strategię, strategia wykonała

validate()
, zwrócony obiekt wylądował w
request.user
- i dopiero teraz metoda kontrolera może po niego sięgnąć. Jeśli weryfikacja się nie powiodła, metoda nie wykona się wcale: guard zatrzymuje żądanie, zanim dojdzie ono do kontrolera.

Podsumowanie

Strażnica stoi, a Ty umiesz zatrudnić w niej dowolnego wartownika:

  • Passport.js to framework do uwierzytelniania, w którym każdy sposób logowania jest osobną strategią - ekosystem daje ponad 500 gotowych,
  • warstwy układają się od dołu: strategia (jak sprawdzić), guard (kogo zawołać),
    @UseGuards()
    (gdzie postawić),
  • strategia dziedziczy po
    PassportStrategy(Strategy)
    , konfiguruje się przez
    super({...})
    i implementuje
    validate()
    ,
  • validate()
    zwraca użytkownika, który trafia do
    request.user
    , a przy odrzuceniu rzuca
    UnauthorizedException
    ,
  • guard to zwykle pusta klasa:
    extends AuthGuard('local')
    ,
    AuthGuard('jwt')
    ,
    AuthGuard('google')
    ,
  • handleRequest
    nadpisujesz, gdy chcesz własną obsługę: sprawdź błąd, sprawdź użytkownika, zwróć obiekt,
  • strategie zewnętrzne różnią się tylko pakietem i konfiguracją;
    clientID
    i
    clientSecret
    czytaj z
    ConfigService
    , nigdy z kodu,
  • drugi argument
    PassportStrategy(Strategy, 'nazwa')
    nadaje strategii nazwę, po której znajdzie ją guard.

W następnej lekcji zejdziemy poziom niżej - do ról i uprawnień, czyli pytania, co wolno gościowi, którego już wpuściliśmy. A na razie zapamiętaj: strategia wie, jak sprawdzić dokument, guard wie, którego wartownika zawołać, a

request.user
to meldunek, który zostaje po udanej kontroli.

Przejdź do CodeWorlds