Kurs NestJS · Moduł 4: Uwierzytelnianie
Role-based Authorization - hierarchia w Imperium
W tej lekcji5
Legionista pokazał przepustkę i wszedł do obozu. Wiemy już, kim jest - to uwierzytelnienie, którym zajmowaliśmy się dotąd. Ale czy wolno mu wejść do skarbca? Rozwiązać kohortę? Wypłacić żołd?
To już inne pytanie: nie "kto to jest", tylko "co mu wolno". Nazywamy je autoryzacją i w Imperium odpowiada na nie ranga. Rekrut i legat mają tę samą przepustkę, ale nie te same prawa. Ta lekcja jest o tym, jak zapisać rzymską hierarchię w kodzie.
Role jako drabina
Zacznijmy od wyliczenia rang - od rekruta po dowódcę legionu:
1export enum Role {
2 TIRO = 'TIRO', // rekrut
3 MILES = 'MILES', // żołnierz
4 CENTURION = 'CENTURION', // dowódca centurii
5 LEGATUS = 'LEGATUS', // dowódca legionu
6}Sam enum wystarczyłby, gdyby role były rozłączne. Ale w wojsku tak nie jest: centurion może wszystko to, co żołnierz, i jeszcze więcej. Ranga to nie etykieta, tylko szczebel drabiny - a drabinę zapisujemy liczbami:
1export const ROLE_HIERARCHY = {
2 [Role.LEGATUS]: 90,
3 [Role.CENTURION]: 80,
4 [Role.MILES]: 50,
5 [Role.TIRO]: 40,
6};
7
8export function hasRequiredRole(userRole: Role, requiredRole: Role): boolean {
9 return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
10}Cała hierarchia sprowadza się do jednego porównania: >=. Legat (90) przejdzie tam, gdzie wymagany jest centurion (80), bo stoi wyżej na drabinie. Rekrut (40) nie przejdzie nigdzie poza własnym szczeblem.
Zwróć uwagę na odstępy między liczbami - 40, 50, 80, 90, a nie 1, 2, 3, 4. To celowe: gdy za rok dojdzie ranga między żołnierzem a centurionem, wpiszesz jej 60 i nie ruszasz reszty. Numerowanie co dziesięć to drobiazg, który oszczędza przepisywania.
Dekorator - zapisanie wymagania przy metodzie
Skoro wiemy, jak porównywać rangi, trzeba jeszcze przy każdym endpointie zapisać, jakiej rangi wymaga. Służy do tego własny dekorator:
1import { SetMetadata } from '@nestjs/common';
2
3export const ROLES_KEY = 'roles';
4export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);To trzy linijki, ale dzieje się w nich rzecz, którą warto nazwać. SetMetadata nie sprawdza niczego - przypina tylko notatkę do metody kontrolera, pod kluczem ROLES_KEY. Notatka leży sobie i czeka; ktoś inny będzie musiał ją przeczytać i wyciągnąć wnioski.
Zapis (...roles: Role[]) to parametr resztowy - pozwala wywołać @Roles(Role.CENTURION) albo @Roles(Role.CENTURION, Role.LEGATUS), a role trafią do tablicy.
Guard - odczytanie notatki i decyzja
Notatkę czyta guard. To on jest w tym układzie wartownikiem, który porównuje rangę przybysza z rangą wymaganą przy drzwiach:
1@Injectable()
2export class RolesGuard implements CanActivate {
3 constructor(private reflector: Reflector) {}
4
5 canActivate(context: ExecutionContext): boolean {
6 const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
7 context.getHandler(),
8 context.getClass(),
9 ]);
10
11 if (!requiredRoles) {
12 return true;
13 }
14
15 const { user } = context.switchToHttp().getRequest();
16
17 return requiredRoles.some((role) => hasRequiredRole(user.role, role));
18 }
19}Prześledźmy to po kolei, bo tu spina się cała lekcja. Reflector to narzędzie NestJS do odczytywania metadanych - tych samych, które przypiął SetMetadata. Metoda getAllAndOverride szuka notatki w dwóch miejscach: najpierw przy metodzie (getHandler()), potem przy klasie kontrolera (getClass()), i wygrywa to, co bliżej metody. Dzięki temu możesz ustawić wymóg dla całego kontrolera, a przy jednym endpointie go nadpisać.
Brak notatki znaczy, że endpoint nie stawia wymagań - wtedy return true wpuszcza każdego zalogowanego. To ważne zachowanie: guard, który przy braku metadanych blokowałby dostęp, zamknąłby całą aplikację.
context.switchToHttp().getRequest() sięga po żądanie, a z niego po user - obiekt, który wcześniej zostawiła tam strategia JWT. I tu widać, dlaczego kolejność ma znaczenie: RolesGuard działa tylko wtedy, gdy przed nim zadziałał guard uwierzytelniający. Bez tego user byłby pusty. Autoryzacja zawsze następuje po uwierzytelnieniu - najpierw wiemy, kim jest przybysz, dopiero potem sprawdzamy jego rangę.
Ostatnia linia zwraca true lub false, a to jedyna rzecz, jakiej NestJS od guarda oczekuje: false zatrzymuje żądanie z odpowiedzią 403, a metoda kontrolera nie wykona się w ogóle. Metoda some wystarcza, bo lista ról to alternatywa - dość spełnić jedno z wymagań.
Użycie w kontrolerze
Oba klocki spinamy przy endpointach:
1@Controller('treasury')
2@UseGuards(JwtAuthGuard, RolesGuard)
3export class TreasuryController {
4 @Get()
5 findAll() {
6 return this.treasuryService.findAll();
7 }
8
9 @Post('withdraw')
10 @Roles(Role.CENTURION)
11 withdraw(@Body() dto: WithdrawDto) {
12 return this.treasuryService.withdraw(dto);
13 }
14
15 @Delete('cohort/:id')
16 @Roles(Role.LEGATUS)
17 disbandCohort(@Param('id') id: string) {
18 return this.treasuryService.disbandCohort(id);
19 }
20}Kolejność w @UseGuards(JwtAuthGuard, RolesGuard) nie jest przypadkowa - guardy wykonują się od lewej do prawej, więc najpierw ustalamy tożsamość, potem rangę. Odwrócenie tej pary sprawiłoby, że RolesGuard szukałby user, którego jeszcze nie ma.
Zauważ też, że findAll() nie ma dekoratora @Roles - i właśnie dlatego zobaczy go każdy zalogowany legionista. Wypłata wymaga centuriona, a rozwiązanie kohorty - legata. Ten sam skarbiec, trzy różne progi.
Podsumowanie
Hierarchia Imperium zapisana, wartownicy na miejscach:
- uwierzytelnienie odpowiada, kim jest przybysz; autoryzacja - co mu wolno,
- role układamy w drabinę liczb, a całe dziedziczenie uprawnień sprowadza się do porównania
>=, - numeruj co dziesięć, żeby dało się później wcisnąć nową rangę bez przepisywania,
SetMetadatatylko przypina notatkę do metody - niczego nie sprawdza,Reflector.getAllAndOverrideczyta notatkę z metody i z klasy, a bliższa metodzie wygrywa,- brak metadanych musi znaczyć "wpuść", inaczej guard zamknie całą aplikację,
- guard zwraca
truealbofalse;falsekończy żądanie odpowiedzią 403, - w
@UseGuards()guardy działają od lewej do prawej - uwierzytelniający musi stać przed autoryzującym, bo to on zostawiauserw żądaniu.
W następnej lekcji zajmiemy się odnawianiem przepustek, czyli refresh tokenami - bo przepustka centuriona też kiedyś wygasa. A na razie zapamiętaj: rola to szczebel drabiny, dekorator przypina notatkę o wymaganej wysokości, a guard porównuje jedno z drugim.
Kod do tej lekcji: src/auth/roles.guard.ts
1// Role-based Authorization - Hierarchia w Imperium
2import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
3import { Reflector } from '@nestjs/core';
4
5// Enum rol w Imperium
6export enum Role {
7 TIRO = 'TIRO',
8 MILES = 'MILES',
9 DECANUS = 'DECANUS',
10 OPTIO = 'OPTIO',
11 CENTURION = 'CENTURION',
12 LEGATUS = 'LEGATUS',
13 ADMIN = 'ADMIN',
14}
15
16// Hierarchia rol
17export const ROLE_HIERARCHY: Record<Role, number> = {
18 [Role.ADMIN]: 100,
19 [Role.LEGATUS]: 90,
20 [Role.CENTURION]: 80,
21 [Role.OPTIO]: 70,
22 [Role.DECANUS]: 60,
23 [Role.MILES]: 50,
24 [Role.TIRO]: 40,
25};
26
27// TODO: Zaimplementuj funkcje sprawdzajaca uprawnienia
28// Porownaj poziom roli uzytkownika z wymagana rola
29export function hasPermission(userRole: Role, requiredRole: Role): boolean {
30 // TODO: Zwroc true jezeli userRole >= requiredRole w hierarchii
31 return false;
32}
33
34// Dekorator @Roles()
35import { SetMetadata } from '@nestjs/common';
36export const ROLES_KEY = 'roles';
37export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
38
39// TODO: Zaimplementuj RolesGuard
40// 1. Uzyj Reflector do pobrania wymaganych rol
41// 2. Pobierz uzytkownika z request.user
42// 3. Sprawdz czy uzytkownik ma wymagana role
43@Injectable()
44export class RolesGuard implements CanActivate {
45 constructor(private reflector: Reflector) {}
46
47 canActivate(context: ExecutionContext): boolean {
48 // TODO: Pobierz wymagane role z metadanych
49 const requiredRoles = this.reflector.getAllAndOverride<Role[]>(
50 ROLES_KEY,
51 [context.getHandler(), context.getClass()],
52 );
53
54 if (!requiredRoles) {
55 return true; // Brak ograniczen
56 }
57
58 // TODO: Pobierz uzytkownika z requestu
59 const { user } = context.switchToHttp().getRequest();
60
61 // TODO: Sprawdz czy uzytkownik ma jedna z wymaganych rol
62 // Uzyj hasPermission() lub requiredRoles.some()
63 return false;
64 }
65}
66
67console.log('RolesGuard - kontrola dostepu oparta na rolach');
68console.log('Roles: TIRO < MILES < DECANUS < OPTIO < CENTURION < LEGATUS < ADMIN');
69Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaki jest główny cel Role-based Authorization (RBAC)?
Zadania praktyczne w grze
- Klikanie w kolejności
Uszereguj role od najniższej do najwyższej: