|
| 1 | +--- |
| 2 | +title: 4. Autoryzacja i walidatory |
| 3 | +description: Autoryzacja, Walidatory |
| 4 | +sidebar: |
| 5 | + order: 5 |
| 6 | +--- |
| 7 | + |
| 8 | +## Wstęp |
| 9 | + |
| 10 | +Autoryzacja jest potrzebna w zasadzie w każdej aplikacji. Nawet jeżeli nasze API jest całkowicie publiczne i dostępne dla każdego, |
| 11 | +to przydałoby się aby nowe treści można było dodać poprzez pewnego rodzaju panel administratora. W tym rozdziale zostaną wytłomaczone podstawy |
| 12 | +przebiegu i metod autoryzacji, opisane kilka dobrych praktyk związanych z nimi oraz, dodatkowo, znajdzie się sekcja o walidacji danych wejściowych. |
| 13 | + |
| 14 | + |
| 15 | +Kod zawarty w tym poradniki można zobaczyć w całości w [repozytorium z Wakacyjnego Wyzwania 2025](https://github.com/Solvro/backend-wakacyjne-wyzwanie-2025) |
| 16 | + |
| 17 | +---------------- |
| 18 | + |
| 19 | +## Autoryzacja |
| 20 | + |
| 21 | +### 1. „Co zrobić z tym hasłem?” |
| 22 | + |
| 23 | +Nieodłącznym elementem autoryzacji jest potrzeba udowodnienia, że ty, jako osoba wysyłająca zapytanie, jesteś tym za kogo się podajesz. |
| 24 | + |
| 25 | +Najprostszym sposobem jest użycie zwykłego hasła dostępu, powiązanego z kontem danego użytkownika. Jednak sama obecność hasła stwarza zagrożenie dla |
| 26 | +developera aplikacji - hasła mogą bowiem wyciec, czy zostać ukradzione. Dlatego należy zastanowić się w jaki sposób takie hasło można bezpiecznie |
| 27 | +przechowywać. Tutaj pojawiają się dwa słowa: hashowanie oraz enkrypcja. Poniżej definicje z Oxford Languages Dictionary (przetłumaczone): |
| 28 | + |
| 29 | +**Enkrypcja (Encryption)** - *odwracalny proces zamiany informacji lub danych w kod/szyfr, szczególnie aby uniemożliwić nieautoryzowany dostęp.* |
| 30 | + |
| 31 | +**Hashowanie (Hashing)** - *zamiana kawałka danych na numeryczny lub alfanumeryczny łańcuch znaków za pomocą funkcji, której rezultat ma zawsze tą samą długość.* |
| 32 | + |
| 33 | +Do hasła jednak, najlepsze jest hashowanie - wtedy, nawet w przypadku wycieku danych, złodziej nie jest w stanie odzyskać pierwotnego hasła z hashu. Zatem typowy *flow* rejestracji użytkownika oraz |
| 34 | +logowania wygląda następująco: |
| 35 | + |
| 36 | +Flow rejestracji: |
| 37 | + |
| 38 | +1. Hasło jako łańcuch znaków (plaintext) |
| 39 | +2. Hashowane za pomocą wyznaczonej funkcji |
| 40 | +3. Przechowujemy tylko hash |
| 41 | + |
| 42 | +Flow logowania: |
| 43 | + |
| 44 | +1. Hasło jako łańcuch znaków (plaintext) |
| 45 | +2. Hashowane za pomocą wyznaczonej funkcji |
| 46 | +3. Wyciągamy hash hasła z bazy danych |
| 47 | +4. Porównujemy dwie wartości do siebie |
| 48 | + |
| 49 | +Charakterystyka każdej poprawniej funkcji hashującej gwarantuje nam unikalny hash dla każdego ciągu znaków, i zawsze ten sam hash dla tego samego ciągu znaków. |
| 50 | + |
| 51 | +Pozostaje nam pytanie, jaki hash wybrać? Najpopularniejszą opcją dla haseł jest funkcja haszująca “bcrypt” wynaleziony w 1999 roku. |
| 52 | +Złożoność hashu powstałego z tej funkcji można modyfikować wybierając liczbę rund hashowania - implementacja w JS (node.bcrypt.js), jako wartość domyślną, wybiera 10 rund, |
| 53 | +a wartością maksymalną jest 20. Biblioteka ma bardzo proste API, przykładowo, do sprawdzenia czy hasło zgadza się z hashem wystarczy użycie funkcji `compare`: |
| 54 | + |
| 55 | +```ts |
| 56 | +async signIn(email: string, password: string): Promise<LoginResponseDto> { |
| 57 | + const user = await this.usersService.findOne(email); |
| 58 | + if (user === null || !await compare(password, user.password).catch(() => false)) { |
| 59 | + throw new UnauthorizedException(); |
| 60 | + } |
| 61 | + return {token: this.generateToken(user.email)}; |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +### 2. „Endpoint to koniec drogi zapytania - Interceptors” |
| 66 | + |
| 67 | +Jednak użytkownik może wysyłać wiele zapytań, a przesyłanie i weryfikowanie hasła za każdym razem stwarza zagrożenie bezpieczeństwa - |
| 68 | +powinniśmy dążyć do tego, by hasło było przesyłane jak najrzadszej. Tutaj z pomocą przychodzą ciastka i headery, w których możemy zawrzeć |
| 69 | +dodatkowe informacje, automatycznie, nie robiąc trudu użytkownikowi. O tym czym są ciastka i headery było opowiedziane, w poprzednich etapach |
| 70 | +kursu, dlatego już bez zbędnego przedłużania, zastanówmy się jak można ich użyć do autoryzacji. Najprostszym i najlepszym pomysłem jest następujący |
| 71 | +flow zapytania: |
| 72 | + |
| 73 | +Flow każdego zapytania wymagającego autoryzacji: |
| 74 | + |
| 75 | +1. Klient automatycznie dodaje unikalną informację do każdego zapytania |
| 76 | +2. Weryfikujemy tą informację na serwerze |
| 77 | +3. Zapytanie przechodzi dalej lub zostaje odrzucone |
| 78 | + |
| 79 | +Z pomocą tutaj przychodzą *interceptory*, zwane czasami *filtrami*, czy *guardami*. Idea jest jedna - zareaguj na, przekształć, lub odrzuć zapytanie. |
| 80 | + |
| 81 | + |
| 82 | + |
| 83 | +My zajmiemy się tzw. Guards, które działają jak służba celna - mają na celu zbadać, czy zapytanie może wejść do naszego endpoint’u. Powstaje jednak pytanie, co badać i jak badać? |
| 84 | + |
| 85 | +Temat jest bardzo obszerny, ale my skupimy się na jednym z najpopularniejszych i |
| 86 | + nie wymagającym utrzymania żadnego stanu na naszym serwerze rozwiązaniu - dzięki temu nie będzie problemów z utrzymaniem sesji. |
| 87 | + |
| 88 | +Podstawowe kryteria: |
| 89 | +- unikalna wartość dla każdego użytkownika |
| 90 | +- z wartości można uzyskać identyfikator użytkownika |
| 91 | +- wartość jest przekazana klientowi podczas logowania |
| 92 | + |
| 93 | +Dodatkowo (dzisiaj się tym nie zajmujemy): |
| 94 | + |
| 95 | +- wartość powinna być unikalna dla każdego logowania |
| 96 | +- wartość powinna mieć swój czas ważności |
| 97 | +- wartość powinna być zaenkryptowana by uniemożliwić zmianę danych w niej zawartych |
| 98 | + |
| 99 | +Wady rozwiązania: |
| 100 | + |
| 101 | +- ukradziona wartość pozwala się bez żadnych problemów podszyć pod właściciela wartości |
| 102 | +- brak wiedzy o zalogowanych urządzeniach (wartość nie jest zależna od urządzenia) |
| 103 | + |
| 104 | +Oczywiście, nawet te problemy mają swoje rozwiązania, ale w tym poradniku nie będziemy się nimi przejmować. Poniżej znajduje się najprostsze rozwiązanie |
| 105 | +spełniające trzy podstawowe kryteria (**oczywiście nie jest ono w żadnym wypadku bezpiecznie - jedynie poglądowe**) |
| 106 | + |
| 107 | +```ts |
| 108 | +// Walidacja tokenów |
| 109 | +async validateToken(token: string): Promise<UserMetadata> { |
| 110 | + return token.startsWith(this.tokenPrefix) ? |
| 111 | + await this.usersService.findMetadataOrFail(token.slice(this.tokenPrefix.length)) |
| 112 | + : Promise.reject(new Error("Invalid token")); |
| 113 | +} |
| 114 | +// Generowanie tokenów |
| 115 | +generateToken(email: string): string { |
| 116 | + return `${this.tokenPrefix}${email}`; |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +W NestJS, każdy *Guard* implementuje metodę `canActivate` zwracającą wartości boolean. Jeżeli metoda zwróci fałsz lub rzuci wyjątek, |
| 121 | +zapytanie jest odrzucane. W przypadku wartości `true`, zapytanie przechodzi do następnego interceptora, lub, jeżeli już nie ma żadnego w kolejce, do naszego |
| 122 | +endpointu. |
| 123 | + |
| 124 | +```ts |
| 125 | +// Autoryzacja zapytania w interceptorze |
| 126 | +async canActivate(context: ExecutionContext): Promise<boolean> { |
| 127 | + const request: RequestWithUser = context.switchToHttp().getRequest(); |
| 128 | + const token = this.extractTokenFromHeader(request); |
| 129 | + if (token === undefined) { |
| 130 | + throw new UnauthorizedException("Missing token"); |
| 131 | + } |
| 132 | + try { |
| 133 | + request.user = await this.authService.validateToken(token); |
| 134 | + } catch (error) { |
| 135 | + throw new UnauthorizedException((error as Error).message); |
| 136 | + } |
| 137 | + return true; |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +### 3. „Nie zawsze wszystko jest dla wszystkich, czyli po co dzielić nasze endpointy?” |
| 142 | + |
| 143 | +Przy podstawowej konfiguracji w NestJS, każdy endpoint z adnotacją `@UseGuards(AuthGuard)` staje się prywatny. Analogicznie, wszystkie bez tej adnotacji są publiczne. |
| 144 | + |
| 145 | +Często wymaganie jest tzw. "Read-Only Public API", czyli API, które jest dostępne publicznie dla każdego do odczytu, ale jakiekolwiek modyfikacje |
| 146 | +wymagają dodatkowych uprawinień. |
| 147 | + |
| 148 | + |
| 149 | + |
| 150 | +### 4. “Wspólne i nasze – Public & Private API pattern” |
| 151 | + |
| 152 | +Aby ułatwić i rozgraniczyć API, z pomocą przychodzi wzorzec "Public & Private API". Podczas kursu nie będziemy używać tego wzorca, ponieważ łamie on niektóre zasady REST. Wspominam o nim, ponieważ jest czasami używany i osobiście jestem jego fanem 👍 |
| 153 | + |
| 154 | +Idea jest prosta: |
| 155 | + |
| 156 | +- wszystkie endpointu zaczynające się z prefixem `/public` lub `/na` (non-authenticated), itp. są dostępne publiczne |
| 157 | +- wszystkie inne są prywatne |
| 158 | + |
| 159 | + |
| 160 | + |
| 161 | +Zalety wzorca to m.in. łatwe instalowanie interceptorów oraz wiedza co wymaga zalogowania, a co nie, bez zagłębiania się w dokumentację. |
| 162 | +Jako wady można wymienić, obecność tych samych zasobów pod innymi ścieżkami, czy np. dwa razy więcej kontrolerów. |
| 163 | + |
| 164 | +### 5. „Ta część jest tylko dla mnie, czyli RBAC” |
| 165 | + |
| 166 | +Czasami jednak samo zalogowanie się na konto nie wystarczy, albowiem nie każdy użytkownik jest równy. Pojawia się nowe pojęcie, często mylone z tym przez Was już znanym, |
| 167 | +czyli Authentication i Authorization. |
| 168 | + |
| 169 | +Authentication, to sprawdzenia kim jest dany użytkownik. Można rozumieć to jako przedstawienie się. Udowodnienie, że nie jest się obcym. Innymi słowy, zalogowanie się. |
| 170 | +Authorization, to sprawdzenie jakie uprawnienia ma dany użytkownik. Tutaj metaforom może być sprawdzenie jakie ma wykształcenie, gdzie pracuje, ile ma lat. |
| 171 | + |
| 172 | +W aplikacjach problem uprawnień często się pojawia. Najprostszym przykładem będzie użytkownik zwykły oraz użytkownik Administrator. Najprostsze rozwiązanie (ale nie najlepsze), to **RBAC**, skrót od **Role Based Access Control**. |
| 173 | +Każdy użytkownik ma jedną, lub kilka, przypisanych roli, a do niektórych działań wymagane są poszczególne role. |
| 174 | + |
| 175 | + |
| 176 | + |
| 177 | +Warto też zaznaczyć różnicę pomiędzy kodem błędu 401 i 403. 401 oznacza, że serwer nie wie kim jesteś. 403 natomiast, oznacza, że serwer wie kim jesteś, ale nie ma uprawnień by zrobić to co chciałeś. |
| 178 | + |
| 179 | +Implementacja podstawowego RBAC w NestJS jest prosta. Załóżmy, że każdy użytkownik ma przypisaną jedną rolę. |
| 180 | + |
| 181 | +```ts |
| 182 | +// RoleGuard, interceptor, który sprawdza uprawnienia |
| 183 | +@Injectable() |
| 184 | +export class RoleGuard implements CanActivate { |
| 185 | + constructor(private reflector: Reflector) { |
| 186 | + } |
| 187 | + |
| 188 | + canActivate(context: ExecutionContext): boolean { |
| 189 | + const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [ |
| 190 | + context.getHandler(), |
| 191 | + context.getClass(), |
| 192 | + ]); |
| 193 | + if (requiredRoles.length === 0) { |
| 194 | + return true; |
| 195 | + } |
| 196 | + const request: RequestWithUser = context.switchToHttp().getRequest(); |
| 197 | + return request.user !== undefined && requiredRoles.includes(request.user.role); |
| 198 | + } |
| 199 | +} |
| 200 | +// dekorator (adnotacja) do endpointów |
| 201 | +export const ROLES_KEY = 'roles'; |
| 202 | +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); |
| 203 | +``` |
| 204 | + |
| 205 | +Użycie takiego interceptora jest dość intuicyjne i proste. Wystarczy dodać adnotację `@UserGuards` nad endpointem, tak jak w przypadku naszego "AuthGuard", |
| 206 | +oraz adnotację `@Roles` z naszego dekoratora z wymienionymi rolami. Przykładowo, endpoint poniżej jest dostępny jedynie dla administratora. |
| 207 | + |
| 208 | +```ts |
| 209 | +@UseGuards(AuthGuard, RoleGuard) |
| 210 | +@Roles(Role.ADMIN) |
| 211 | +async disableUser(@Param('email') email: string) { |
| 212 | + return this.userService.disableAccount(email); |
| 213 | +} |
| 214 | +``` |
| 215 | + |
| 216 | +Pozostaje jeszcze odpowiedź na pytanie, czemu takie rozwiązanie nie jest najlepsze. Powodem jest ilość ról, a dokładniej nakładanie się uprawnień. |
| 217 | +Przykład poniżej pochodzi z jednego z projektów, które pisałem (kod jest z Javy): |
| 218 | + |
| 219 | +```java |
| 220 | +enum Permission { |
| 221 | + ADMIN(128), |
| 222 | + TEAM_OWNER(64), |
| 223 | + DRIVER(32) |
| 224 | + RACE(16), |
| 225 | + TASK(8), |
| 226 | + EVENT_SEND(4), |
| 227 | + EVENT_RECEIVE(2), |
| 228 | + USER(1), |
| 229 | + BLOCKED(0); |
| 230 | +} |
| 231 | +``` |
| 232 | + |
| 233 | +Co jeżeli użytkownik może wysyłać eventy, nie może ich odbierać, jest kierowcą, użytkownikiem, może zarządzać zadaniami, ale nie może zarządzać wyścigiem? |
| 234 | + |
| 235 | +Rozwiązania są dwa: |
| 236 | + |
| 237 | +1. Pozwalamy aby jeden użytkownik miał kilka roli (dodatkowe tabele w bazie danych i overhead na bardzo częstej operacji) |
| 238 | +2. Tworzymy hybrydowe role: EventSendDriverTaskUser... (i wszystkie możliwe kombinacje?) |
| 239 | + |
| 240 | +Żadne z nich nie jest dobre - są to limitacje prostego RBAC. Najlepszym rozwiązaniem jest zmiany systemu kontroli na inny (np. ACL). |
| 241 | +Czasami jednak trzeba customowy system samemu napisać, bo większość frameworków oferuje jedynie wbudowane implementacje dla RBAC. |
| 242 | + |
| 243 | +### 6. „Nie lubimy obcych - CORS” |
| 244 | + |
| 245 | +Kilka słów o **CORS** (skrót od **Cross-Origin Resource Sharing**). |
| 246 | + |
| 247 | +Jest to mechanizm dla przeglądarek internetowych, który kontroluje czy dana domena może uzyskać zasoby od innej. Przed wysłaniem prawdziwego |
| 248 | +zapytania, klient wysyła zapytanie typu OPTIONS, tzw. "preflight request" z danymi klienta, które trafia do pierwszego interceptora na serwerze. Tylko, i tylko wtedy gdy, odpowiedź |
| 249 | +na to zapytanie jest pozytywna, to klient wysyła drugie, już właściwe, zapytanie. |
| 250 | + |
| 251 | + |
| 252 | + |
| 253 | +Konfiguracja CORS w NestJS jest bardzo prosta i wymaga w zasadzie jednej linijki kodu: |
| 254 | + |
| 255 | +```ts |
| 256 | +/// main.ts |
| 257 | +app.enableCors({ |
| 258 | + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], |
| 259 | + origin: 'http://localhost:5500', |
| 260 | + preflightContinue: false, |
| 261 | +}); |
| 262 | +``` |
| 263 | +Określamy jakie typy zapytań są dopuszczalne, i jakie źródła akceptujemy. |
| 264 | + |
| 265 | +### 7. „Nasze 500, to dalej 500 – Walidatory” |
| 266 | + |
| 267 | +Nieodłączną częścią każdego API jest walidacja danych wejściowych. Pozwala to nie tylko uniknąć nieprawidłowych danych, ale również |
| 268 | +uchronić nas przed nieoczekiwanymi błędami. NestJS bez problemów wspiera różne techniki walidacji danych, a zaczęcie wymaga jedynie instalacji |
| 269 | +pakietów: `npm i --save class-validator class-transformer`. |
| 270 | + |
| 271 | +#### Pipes |
| 272 | + |
| 273 | +Pipe (dosłowne, ale raczej nie używane tłumaczenie - "rura"), to element biblioteki `class-transformer`. Celem jest transformacja i jednoczenie |
| 274 | +walidacja danych. Spójrz na przykład poniżej używający wbudowanej `ParseIntPipe`: |
| 275 | + |
| 276 | +```ts |
| 277 | +@Get(":id") |
| 278 | +async findOne(@Param("id", ParseIntPipe) id: number) { |
| 279 | + return this.tripService.findOne(id); |
| 280 | +} |
| 281 | + |
| 282 | +@Patch(":id") |
| 283 | +async update(@Param("id") id: string, @Body() updateTripDto: UpdateTripDto) { |
| 284 | + return this.tripService.update(+id, updateTripDto); |
| 285 | +} |
| 286 | +``` |
| 287 | +Załóżmy, że klient wyśle zapytanie, gdzie parametr `id` nie będzie liczbą. |
| 288 | + |
| 289 | +Pierwsze rozwiązanie (z `ParseIntPipe`), zwróci do klienta odpowiedź z kodem 400 i informacją o ty, że `id` musi być liczbą całkowitą. |
| 290 | +Drugie zaś, wyrzuci kod błędu 500, bo serwer nie może zamienić nie-liczby na liczbę za pomocą sztuczki z `+`. |
| 291 | + |
| 292 | +### Walidacja DTO |
| 293 | + |
| 294 | +Walidacja DTO jest również prosta. Nad każdym z pól wymagających walidacji umieszczamy odpowiednie dekoratory. W przypadku, gdy walidacja zakończy się |
| 295 | +błędem, użytkownik dostanie odpowiedź z kodem 400 i, opcjonalnie, mniej lub bardzie szczegółową wiadomością o błędzie - zależy to od nas. |
| 296 | +Za pomocą wbudowanych walidatorów można sprawdzić większość potrzebnych kryteriów - nazwa walidatora przekazuje co dany walidator sprawdza. |
| 297 | + |
| 298 | +```ts |
| 299 | +export class CreateTripDto { |
| 300 | + @IsString() |
| 301 | + @Length(3, 255) |
| 302 | + name: string; |
| 303 | + |
| 304 | + @IsOptional() |
| 305 | + @IsNumber() |
| 306 | + @Min(0) |
| 307 | + plannedBudget?: number; |
| 308 | +} |
| 309 | +``` |
| 310 | + |
| 311 | +Jeżeli wbudowane walidatory nie wystarczą, możemy zawsze stworzyć swój własny. Załóżmy, że celem jest moderacja danych użytkownika - jego status |
| 312 | +nie może zawierać pewnych słów. |
| 313 | + |
| 314 | +```ts |
| 315 | +// nice-text.validator.ts |
| 316 | +@ValidatorConstraint({name: 'niceText', async: false}) |
| 317 | +export class NiceText implements ValidatorConstraintInterface { |
| 318 | + validate(text: string, _: ValidationArguments) { |
| 319 | + return !text.includes("loser"); |
| 320 | + } |
| 321 | + |
| 322 | + defaultMessage(_: ValidationArguments) { |
| 323 | + return "That's not a nice text, is it?"; |
| 324 | + } |
| 325 | +} |
| 326 | +``` |
| 327 | + |
| 328 | +Nasz walidator zawiera metodę `validate`, która zwraca fałsz jeżeli niedozwolone słowo zostanie wykryte, oraz metodę `defaultMessage` z informacją dla klienta o tym co poszło nie tak. |
| 329 | +Sposób użycia customowego walidatora jest taki sam jak tych wbudowanych, poprzez dekorator `@Validate(NazwaKlasyWalidatora): |
| 330 | + |
| 331 | +```ts |
| 332 | +export class UserUpdateDto { |
| 333 | + @IsOptional() |
| 334 | + @IsString() |
| 335 | + @MaxLength(30) |
| 336 | + @Validate(NiceText) |
| 337 | + newAboutMe?: string | null; |
| 338 | + @IsOptional() |
| 339 | + @MaxLength(15) |
| 340 | + @IsOptional() |
| 341 | + @IsString() |
| 342 | + name?: string | null; |
| 343 | +} |
| 344 | +``` |
| 345 | + |
| 346 | +## Zadanie do wykonania |
| 347 | + |
| 348 | +Zadanie domowe do wykonania znajduje się na [głównych repozytorium](https://github.com/Solvro/backend-wakacyjne-wyzwanie-2025/blob/main/4.%20Autoryzacja%20i%20walidatory/4.md) |
| 349 | + |
| 350 | +## Materiały |
| 351 | + |
| 352 | +- Link do [nagrania prezentacji](https://drive.google.com/file/d/136PmaiBlXc1wRuipbGF2iG7x-D8HI-r4/view?usp=sharing) z wykładu |
| 353 | +- Slajdy z prezentacji |
| 354 | +- Repozytorium z kodem przedstawionym podczas prezentacji: [link do repozytorium](https://github.com/Solvro/backend-wakacyjne-wyzwanie-2025/blob/main/4.%20Autoryzacja%20i%20walidatory/4.md) |
| 355 | +- https://docs.nestjs.com/security |
| 356 | +- https://github.com/kelektiv/node.bcrypt.js#readme |
| 357 | +- https://github.com/expressjs/cors#configuration-options |
| 358 | +- https://github.com/typestack/class-validator (walidatory używane przez Nest'a) |
0 commit comments