Kurs NestJS · Moduł 4: Uwierzytelnianie

OAuth i Social Login - sojusze Imperium

5 min czytania
W tej lekcji5

Do bramy obozu przychodzi obywatel z prowincji Galia. Możesz kazać mu założyć nowe konto: wymyślić hasło, potwierdzić adres, przejść całą procedurę rekrutacji. Albo zawrzeć sojusz z Galią: jej urzędnicy potwierdzą tożsamość przybysza, a Ty przyjmiesz to potwierdzenie jak własne.

Ten drugi sposób nazywa się OAuth 2.0 - i to on stoi za przyciskiem "Zaloguj przez Google", który znasz z setek stron. Uczy się go w tym miejscu z jednego powodu: hasło obywatela nigdy nie trafia do Twojego obozu. Nie musisz go przechowywać, hashować ani chronić przed wyciekiem, bo nigdy go nie widzisz.

Cztery kroki sojuszu

Zanim spojrzymy na kod, prześledźmy, co dzieje się przy tym przycisku - bo to ta sekwencja jest sercem OAuth, a kod tylko ją obsługuje.

  1. Obywatel klika "Zaloguj przez Google" na Twojej stronie.
  2. Twoja aplikacja przekierowuje go do Google. Od tej chwili on rozmawia z Google, nie z Tobą - i tam, na stronie Google, podaje swoje hasło.
  3. Google odsyła go z powrotem pod ustalony adres, dołączając jednorazowy kod. Twój serwer wymienia ten kod na dane obywatela.
  4. Twoja aplikacja wystawia własny token JWT - od tego momentu obywatel porusza się po obozie na Twojej przepustce, a rola Google się kończy.

Krok czwarty jest tym, o którym najczęściej się zapomina. Google potwierdza tożsamość jeden raz, przy wejściu. Cała późniejsza praca aplikacji opiera się na Twojej własnej przepustce - dokładnie takiej, jaką poznałeś w lekcji o JWT.

Strategia - obsługa powrotu z Galii

Kod tej wymiany bierze na siebie strategia Passport. Szkielet znasz; nowa jest tylko treść metody validate:

1@Injectable()
2export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
3  constructor(
4    private configService: ConfigService,
5    private usersService: UsersService,
6  ) {
7    super({
8      clientID: configService.get('GOOGLE_CLIENT_ID'),
9      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
10      callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
11      scope: ['email', 'profile'],
12    });
13  }
14
15  async validate(accessToken: string, refreshToken: string, profile: any) {
16    const { name, emails } = profile;
17
18    return this.usersService.findOrCreateSocialUser({
19      email: emails[0].value,
20      firstName: name.givenName,
21      provider: 'google',
22      providerId: profile.id,
23    });
24  }
25}

W super() opisujemy sam sojusz. clientID i clientSecret to dokumenty Twojej aplikacji wydane przez Google - czytane z konfiguracji, nigdy wpisane w kod. callbackURL to adres z kroku trzeciego, pod który Google odeśle obywatela; ten sam adres musisz zgłosić w panelu Google, inaczej przekierowanie zostanie odrzucone. scope określa, o co prosisz - tutaj tylko o adres e-mail i podstawowy profil.

Zasada dotycząca scope jest krótka: proś o minimum. Każde dodatkowe uprawnienie to jeden ekran zgody więcej dla użytkownika i jedna rzecz więcej, którą musisz chronić.

Metoda validate uruchamia się dopiero po powrocie z Google, z gotowym profilem obywatela. Zwrócony obiekt trafia - jak w każdej strategii - do request.user.

Dwa identyfikatory zamiast hasła

Zwróć uwagę na parę provider i providerId w danych, które przekazujemy dalej. To ona zastępuje hasło.

providerId to identyfikator obywatela w systemie Google - niezmienny, w przeciwieństwie do adresu e-mail, który da się zmienić. Sam providerId jednak nie wystarcza, bo obywatel o tym samym numerze może istnieć u innego dostawcy. Dlatego zapamiętujemy parę: kto potwierdził i kogo potwierdził.

1async findOrCreateSocialUser(data: SocialUserDto): Promise<User> {
2  const existing = await this.usersRepository.findOne({
3    where: { provider: data.provider, providerId: data.providerId },
4  });
5
6  if (existing) {
7    return existing;
8  }
9
10  return this.usersRepository.save({
11    email: data.email,
12    firstName: data.firstName,
13    provider: data.provider,
14    providerId: data.providerId,
15  });
16}

Nazwa findOrCreate opisuje całą logikę: przy pierwszym logowaniu tworzymy wpis, przy każdym kolejnym go odnajdujemy. Zauważ, czego w tej encji nie ma - kolumny z hasłem. Obywatel przyjęty przez sojusz nigdy hasła nie podawał, więc nie ma czego zapisać. To zresztą pułapka, o którą łatwo się potknąć: jeśli Twoja kolumna password jest wymagana (nullable: false), zapis takiego użytkownika się nie powiedzie.

Dwa endpointy zamykające obieg

Po stronie kontrolera sojusz sprowadza się do dwóch adresów:

1@Controller('auth')
2export class AuthController {
3  @Get('google')
4  @UseGuards(AuthGuard('google'))
5  googleLogin() {
6    // przekierowanie realizuje guard
7  }
8
9  @Get('google/callback')
10  @UseGuards(AuthGuard('google'))
11  googleCallback(@Request() req) {
12    return this.authService.generateToken(req.user);
13  }
14}

Pierwszy endpoint ma puste ciało i to nie pomyłka - jego jedynym zadaniem jest uruchomienie guarda, który przekieruje użytkownika do Google. Kod w tej metodzie nigdy by się nie wykonał.

Drugi to adres z callbackURL. Gdy obywatel wraca, guard wymienia kod na profil, strategia zapisuje lub odnajduje użytkownika, a my na końcu wystawiamy własny token JWT. I tu domyka się krok czwarty: od tej chwili aplikacja nie potrzebuje już Google.

Podsumowanie

Sojusz zawarty, brama otwarta dla obywateli sąsiednich prowincji:

  • OAuth 2.0 pozwala logować się przez zewnętrzny serwis, a hasło nigdy nie trafia do Twojej aplikacji,
  • obieg ma cztery kroki: kliknięcie, przekierowanie do dostawcy, powrót z kodem, wystawienie własnego JWT,
  • rola dostawcy kończy się po zalogowaniu - dalej działa Twoja przepustka,
  • clientID i clientSecret czytamy z konfiguracji, callbackURL musi zgadzać się z adresem zgłoszonym u dostawcy,
  • w scope proś o minimum potrzebnych danych,
  • tożsamość zapamiętujesz jako parę provider + providerId - to ona zastępuje hasło, bo e-mail bywa zmieniany,
  • findOrCreate tworzy wpis przy pierwszym logowaniu i odnajduje go przy każdym kolejnym,
  • użytkownik z OAuth nie ma hasła, więc kolumna password nie może być wymagana,
  • endpoint startowy ma puste ciało - całą pracę wykonuje guard.

W następnej lekcji zajmiemy się sesjami i ciasteczkami, czyli drugą drogą do fortu - stanową alternatywą dla przepustek. A na razie zapamiętaj: OAuth to sojusz, w którym sąsiad ręczy za przybysza; Ty przyjmujesz to ręczenie raz i od razu wystawiasz własny dokument.

Kod do tej lekcji: src/auth/google.strategy.ts
1// OAuth i Social Login - Sojusze Imperium
2import { Injectable } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { Strategy, VerifyCallback } from 'passport-google-oauth20';
5import { ConfigService } from '@nestjs/config';
6
7// TODO: Zaimplementuj GoogleStrategy
8// 1. Rozszerz PassportStrategy(Strategy, 'google')
9// 2. Skonfiguruj clientID, clientSecret, callbackURL, scope
10// 3. Zaimplementuj validate() do przetwarzania profilu Google
11@Injectable()
12export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
13  constructor(private configService: ConfigService) {
14    super({
15      // TODO: Pobierz clientID z configService
16      clientID: '',
17
18      // TODO: Pobierz clientSecret z configService
19      clientSecret: '',
20
21      // URL powrotny po logowaniu Google
22      callbackURL: 'http://localhost:4000/auth/google/callback',
23
24      // Zakresy danych do pobrania
25      scope: ['email', 'profile'],
26    });
27  }
28
29  // TODO: Zaimplementuj validate
30  async validate(
31    accessToken: string,
32    refreshToken: string,
33    profile: any,
34    done: VerifyCallback,
35  ): Promise<any> {
36    const { name, emails, photos } = profile;
37
38    // TODO: Stworz obiekt obywatela z danych Google
39    const citizen = {
40      email: '', // TODO: emails[0].value
41      firstName: '', // TODO: name.givenName
42      lastName: '', // TODO: name.familyName
43      picture: '', // TODO: photos[0].value
44      provider: 'google',
45      accessToken,
46    };
47
48    // TODO: Wywolaj done(null, citizen)
49    done(null, citizen);
50  }
51}
52
53// Kontroler OAuth
54import { Controller, Get, UseGuards, Request } from '@nestjs/common';
55import { AuthGuard } from '@nestjs/passport';
56
57@Controller('auth')
58export class OAuthController {
59  // Przekierowanie do Google
60  @Get('google')
61  @UseGuards(AuthGuard('google'))
62  async googleAuth() {
63    // Passport automatycznie przekieruje do Google
64  }
65
66  // Callback po zalogowaniu
67  @Get('google/callback')
68  @UseGuards(AuthGuard('google'))
69  googleAuthCallback(@Request() req: any) {
70    // req.user zawiera dane z validate()
71    return { message: 'Obywatel z Google dolaczyl!', user: req.user };
72  }
73}
74
75console.log('OAuth 2.0 - logowanie przez zewnetrzne serwisy');
76console.log('Flow: App -> Google -> Callback -> JWT Token');
77

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Co umożliwia OAuth 2.0 w aplikacji webowej?

Zadania praktyczne w grze

  • Układanie w pionie

    Uszereguj kroki logowania OAuth:

Przydatne artykuły