Kurs NestJS · Moduł 4: Uwierzytelnianie
Refresh Tokens - odnawianie przepustek legionu
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żytkownikaStary 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');
68Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
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: