Kurs NestJS · Moduł 4: Uwierzytelnianie

Refresh Tokens - odnawianie przepustek legionu

8 min czytania
W tej lekcji9

Access token z poprzednich lekcji żyje 15 minut. Legionista, którego co kwadrans wyrzuca z systemu, szybko zacznie przeklinać Architekta Vitruviusa. Wydłużenie przepustki do tygodnia to z kolei zaproszenie dla złodzieja: JWT jest ważny aż do daty wygaśnięcia, więc skradziony token otwiera fort przez siedem dni i nie da się go łatwo odwołać. Wyjściem są dwa tokeny o różnych zadaniach.

Czym są Refresh Tokens?

Wyobraź sobie system przepustek w obozie legionowym. Legionariusz dostaje dwa dokumenty:

  • Access Token (krótka przepustka wartownicza) - pozwala na wejście do obozu i wygasa po kilkunastu minutach
  • Refresh Token (pieczęć centuriona) - żyje kilka dni i pozwala wyrobić nową przepustkę bez ponownego stawania przed trybunałem

Dlaczego potrzebujemy dwóch tokenów?

  • Bezpieczeństwo - jeśli ktoś przechwyci access token, ma dostęp tylko na krótki czas
  • Wygoda - użytkownik nie musi logować się co kwadrans
  • Kontrola - możemy unieważnić refresh token, blokując odnowienie dostępu

Mechanizm refresh tokenów to fundament bezpiecznych systemów uwierzytelniania w produkcyjnych aplikacjach.

Generowanie pary tokenów

Oba tokeny podpisujemy osobnymi sekretami, JWT_ACCESS_SECRET i JWT_REFRESH_SECRET, więc jednego nie da się podrobić ani użyć zamiast drugiego. Tak wygląda logowanie:

1// auth/auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import { createHash } from 'node:crypto';
6
7@Injectable()
8export class AuthService {
9  constructor(
10    private jwtService: JwtService,
11    private configService: ConfigService,
12    private usersService: UsersService,
13  ) {}
14
15  async login(loginDto: LoginDto): Promise<TokenPair> {
16    const user = await this.usersService.validateCredentials(
17      loginDto.username,
18      loginDto.password,
19    );
20
21    if (!user) {
22      throw new UnauthorizedException('Nieprawidłowe dane logowania, legionariuszu!');
23    }
24
25    // Generuj parę tokenów
26    const tokens = await this.generateTokenPair(user);
27
28    // Zapisz w bazie skrót SHA-256 refresh tokenu, nie sam token
29    const hashedRefreshToken = this.hashToken(tokens.refreshToken);
30    await this.usersService.updateRefreshToken(user.id, hashedRefreshToken);
31
32    return tokens;
33  }

validateCredentials() sprawdza hasło przez bcrypt.compare() z lekcji o hashowaniu. W bazie ląduje wyłącznie skrót refresh tokenu - access tokenu nie zapisujemy nigdzie, bo po kwadransie i tak wygaśnie. Typ UserDocument poznasz za chwilę przy schemacie.

Parę tokenów tworzy osobna metoda:

1  async generateTokenPair(user: UserDocument): Promise<TokenPair> {
2    const payload = {
3      sub: user.id,
4      username: user.username,
5      role: user.role,
6      cohort: user.cohortName,
7    };
8
9    const [accessToken, refreshToken] = await Promise.all([
10      this.jwtService.signAsync(payload, {
11        secret: this.configService.get('JWT_ACCESS_SECRET'),
12        expiresIn: '15m', // Krótka przepustka wartownicza - 15 minut
13      }),
14      this.jwtService.signAsync(payload, {
15        secret: this.configService.get('JWT_REFRESH_SECRET'),
16        expiresIn: '7d', // Pieczęć centuriona - dłuższy czas życia
17      }),
18    ]);
19
20    return { accessToken, refreshToken };
21  }
22
23  // Skrót refresh tokenu: SHA-256, bo bcrypt czyta tylko 72 bajty
24  private hashToken(token: string): string {
25    return createHash('sha256').update(token).digest('hex');
26  }
27}

signAsync() przyjmuje sekret i expiresIn dla każdego tokenu osobno, a Promise.all() podpisuje oba równolegle. Payload jest ten sam, różnią się tylko sekret i czas życia. Zwróć uwagę na hashToken(): celowo używa SHA-256, a nie bcrypt. bcrypt czyta tylko 72 bajty, a dwa tokeny JWT tego samego legionisty mają identyczny początek (nagłówek i pierwsze pola payloadu), więc bcrypt.compare(staryToken, hashNowego) zwróciłoby true. Token jest długi i losowy, więc szybki skrót w zupełności wystarcza.

Jedna rzecz poza tym plikiem: JwtStrategy musi weryfikować access tokeny tym samym sekretem, którym je podpisujesz. Jeśli wprowadzasz JWT_ACCESS_SECRET, zmień też jej secretOrKey.

Endpoint odnawiania tokenu

Kontroler wystawia trzy trasy: logowanie, odświeżenie i wylogowanie:

1// auth/auth.controller.ts
2@Controller('auth')
3export class AuthController {
4  constructor(private authService: AuthService) {}
5
6  @Post('login')
7  async login(@Body() loginDto: LoginDto) {
8    return this.authService.login(loginDto);
9  }
10
11  @Post('refresh')
12  async refreshTokens(@Body('refreshToken') refreshToken: string) {
13    return this.authService.refreshTokens(refreshToken);
14  }
15
16  @Post('logout')
17  @UseGuards(JwtAuthGuard)
18  async logout(@Req() req) {
19    // Unieważnij refresh token przy wylogowaniu
20    await this.authService.logout(req.user.userId);
21    return { message: 'Legionariusz wylogowany pomyślnie' };
22  }
23}

/auth/refresh nie ma JwtAuthGuard, bo wywołujemy go właśnie wtedy, gdy access token wygasł - poświadczeniem jest tu sam refresh token. Wylogowanie jest chronione, a req.user.userId pochodzi z JwtStrategy, której validate() zwraca { userId, username, role }.

Logika odnawiania w serwisie

Metoda refreshTokens() zaczyna od sprawdzenia, czy pieczęć jest prawdziwa i czyj to dokument:

1// auth/auth.service.ts (kontynuacja)
2async refreshTokens(refreshToken: string): Promise<TokenPair> {
3  // 1. Zweryfikuj refresh token
4  let payload;
5  try {
6    payload = await this.jwtService.verifyAsync(refreshToken, {
7      secret: this.configService.get('JWT_REFRESH_SECRET'),
8    });
9  } catch (error) {
10    throw new UnauthorizedException('Pieczęć centuriona wygasła - zaloguj się ponownie!');
11  }
12
13  // 2. Znajdź użytkownika i sprawdź zapisany refresh token
14  const user = await this.usersService.findById(payload.sub);
15
16  if (!user || !user.hashedRefreshToken) {
17    throw new UnauthorizedException('Dostęp odrzucony!');
18  }

verifyAsync() z sekretem refresh sprawdza podpis i datę wygaśnięcia. Brak zapisanego skrótu oznacza, że legionista się wylogował albo jego sesję unieważniono.

Dalej porównujemy token z bazą i wydajemy nową parę:

1  // 3. Porównaj skrót tokenu z zapisanym w bazie
2  const isTokenValid = this.hashToken(refreshToken) === user.hashedRefreshToken;
3
4  if (!isTokenValid) {
5    // Potencjalna kradzież tokenu - unieważnij wszystkie sesje
6    await this.usersService.updateRefreshToken(user.id, null);
7    throw new UnauthorizedException('Token unieważniony - wykryto podejrzaną aktywność!');
8  }
9
10  // 4. Wygeneruj nową parę tokenów (Token Rotation)
11  const newTokens = await this.generateTokenPair(user);
12
13  // 5. Zapisz nowy refresh token
14  const hashedNewRefreshToken = this.hashToken(newTokens.refreshToken);
15  await this.usersService.updateRefreshToken(user.id, hashedNewRefreshToken);
16
17  return newTokens;
18}
19
20async logout(userId: string): Promise<void> {
21  // Unieważnij refresh token
22  await this.usersService.updateRefreshToken(userId, null);
23}

Jeśli skrót się nie zgadza, ktoś przyniósł poprawnie podpisany, ale nieaktualny token - to sygnał kradzieży, więc kasujemy zapisany skrót i wszyscy muszą zalogować się ponownie. logout() robi to samo na życzenie użytkownika.

Token Rotation - rotacja tokenów

Kluczowa technika bezpieczeństwa: przy każdym odświeżeniu generujemy nowy refresh token i unieważniamy stary. Jeśli ktoś ukradnie refresh token i spróbuje go użyć po rotacji - wykryjemy to:

1// Schemat ochrony przed kradzieżą tokenów
2// 1. Legionariusz loguje się → otrzymuje AT1 + RT1
3// 2. AT1 wygasa → wysyła RT1 → otrzymuje AT2 + RT2 (RT1 unieważniony)
4// 3. Złodziej próbuje użyć RT1 → ODMOWA (token już obrócony)
5// 4. System wykrywa użycie starego tokenu → unieważnia WSZYSTKIE sesje użytkownika

Stary RT1 wciąż ma poprawny podpis i datę ważności. Odrzuca go dopiero porównanie ze skrótem w bazie - i dlatego ten skrót przechowujemy.

Schemat użytkownika z refresh tokenem

Skrót trafia do pola hashedRefreshToken w schemacie Mongoose:

1// users/user.schema.ts (Mongoose)
2import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
3import { HydratedDocument } from 'mongoose';
4
5@Schema({ timestamps: true })
6export class User {
7  @Prop({ required: true, unique: true })
8  username: string;
9
10  @Prop({ required: true })
11  password: string;
12
13  @Prop()
14  email: string;
15
16  @Prop({ default: 'miles' })
17  role: string;
18
19  @Prop({ type: String, default: null })
20  hashedRefreshToken: string | null;
21
22  @Prop()
23  cohortName: string;
24}
25
26export type UserDocument = HydratedDocument<User>;
27export const UserSchema = SchemaFactory.createForClass(User);

Opcja nullable pochodzi z TypeORM, a Mongoose po prostu ją ignoruje. Tutaj null jest dozwolone domyślnie, a przy typie string | null trzeba jawnie podać type: String. HydratedDocument<User> to wzorzec z aktualnej dokumentacji NestJS: klasa opisuje pola, a typ dokumentu dodaje id i metody Mongoose.

Refresh Token Guard

Zamiast weryfikować token ręcznie, możesz oddać to Passportowi. Strategia o nazwie 'jwt-refresh' czyta token z pola body:

1// auth/guards/refresh-token.guard.ts
2import { Injectable } from '@nestjs/common';
3import { AuthGuard } from '@nestjs/passport';
4
5@Injectable()
6export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}
7
8// auth/strategies/refresh-token.strategy.ts
9import { Injectable } from '@nestjs/common';
10import { PassportStrategy } from '@nestjs/passport';
11import { ExtractJwt, Strategy } from 'passport-jwt';
12import { ConfigService } from '@nestjs/config';
13import { Request } from 'express';
14
15@Injectable()
16export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
17  constructor(private configService: ConfigService) {
18    super({
19      jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
20      secretOrKey: configService.getOrThrow('JWT_REFRESH_SECRET'),
21      passReqToCallback: true,
22    });
23  }
24
25  validate(req: Request, payload: any) {
26    const refreshToken = req.body.refreshToken;
27    return { ...payload, refreshToken };
28  }
29}

passReqToCallback: true przekazuje do validate() cały request, więc surowy token trafia do req.user.refreshToken. Strategia sprawdza tylko podpis i datę, dlatego porównanie ze skrótem w bazie i tak zostaje w serwisie. Po stronie klienta refresh token najlepiej trzymać w ciasteczku httpOnly, niedostępnym dla JavaScriptu - to polecam.

Praktyczne ćwiczenie

Zaimplementuj kompletny system refresh tokenów dla legionu, krok po kroku:

1// Twój kod tutaj
2// 1. Skonfiguruj dwa sekrety JWT (access i refresh) w ConfigService
3// 2. Zaimplementuj endpoint POST /auth/refresh z walidacją
4// 3. Dodaj Token Rotation przy każdym odświeżeniu
5// 4. Zaimplementuj wykrywanie ponownego użycia starego tokenu
6// 5. Dodaj czyszczenie wygasłych tokenów (cron job)

Do czyszczenia starych wpisów przyda się @nestjs/schedule z dekoratorem @Cron().

Podsumowanie

Zostałeś mianowany strażnikiem bram Imperium! Teraz umiesz:

  • Generować pary tokenów - access token (krótki) + refresh token (długi)
  • Implementować Token Rotation - nowy refresh token przy każdym odświeżeniu
  • Wykrywać kradzież tokenów - unieważnianie sesji przy ponownym użyciu starego tokenu
  • Bezpiecznie przechowywać refresh tokeny w bazie jako skrót SHA-256
  • Implementować logout - unieważnianie refresh tokenu przy wylogowaniu
  • Tworzyć Passport Strategies dla refresh tokenów

W następnej lekcji zajmiemy się OAuth i logowaniem przez Google.

Pamiętaj: przepustka wartownicza ma być krótka, a pieczęć centuriona - jednorazowa, bo każda rotacja zamyka drogę temu, kto ukradł poprzednią.

Kod do tej lekcji: src/auth/refresh-tokens.ts
1// Refresh Tokens - Odnawianie przepustek legionu
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5
6interface TokenPair {
7  accessToken: string;
8  refreshToken: string;
9}
10
11interface JwtPayload {
12  sub: string;
13  username: string;
14  role: string;
15}
16
17@Injectable()
18export class RefreshTokenService {
19  constructor(
20    private jwtService: JwtService,
21    private configService: ConfigService,
22  ) {}
23
24  // TODO: Zaimplementuj generowanie pary tokenow
25  // accessToken - krotki czas zycia (15m)
26  // refreshToken - dlugi czas zycia (7d)
27  async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
28    // TODO: Wygeneruj accessToken
29    const accessToken = this.jwtService.sign(payload, {
30      secret: '', // TODO: pobierz JWT_ACCESS_SECRET z configService
31      expiresIn: '', // TODO: ustaw na '15m'
32    });
33
34    // TODO: Wygeneruj refreshToken z innym sekretem
35    const refreshToken = this.jwtService.sign(payload, {
36      secret: '', // TODO: pobierz JWT_REFRESH_SECRET z configService
37      expiresIn: '', // TODO: ustaw na '7d'
38    });
39
40    return { accessToken, refreshToken };
41  }
42
43  // TODO: Zaimplementuj odswiezanie tokenow
44  async refreshTokens(oldRefreshToken: string): Promise<TokenPair> {
45    try {
46      // TODO: Zweryfikuj stary refreshToken
47      const payload = this.jwtService.verify(oldRefreshToken, {
48        secret: '', // TODO: ten sam sekret co przy generowaniu refresh
49      });
50
51      // TODO: Wygeneruj nowa pare tokenow
52      const newPayload: JwtPayload = {
53        sub: payload.sub,
54        username: payload.username,
55        role: payload.role,
56      };
57
58      return this.generateTokenPair(newPayload);
59    } catch (error) {
60      throw new UnauthorizedException('Refresh token wygasl lub jest nieprawidlowy!');
61    }
62  }
63}
64
65console.log('Access Token: krotki czas zycia (15 minut)');
66console.log('Refresh Token: dlugi czas zycia (7 dni)');
67console.log('Rotacja tokenow zwieksza bezpieczenstwo');
68

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. Jaka jest typowa różnica w czasie życia między Access Token a Refresh Token?

Zadania praktyczne w grze

  • Układanie w pionie

    Uszereguj kroki odświeżania tokenu JWT:

Przydatne artykuły