Strona główna / Artykuły

Autoryzacja a upoważnianie: gdzie każde z nich znajduje się w twoim kodzie

Autoryzacja potwierdza, kim jesteś, a upoważnienie decyduje o tym, co możesz robić. Gdzie każda z nich znajduje się w middleware, 401 kontra 403, oraz błędy wynikające z ich pomieszania.

Wydawca: REACTAPP.TOP6 min czytania1335 słów

Źródła: Authentication vs Authorization: The Distinction Most Developers Get Wrong

Dwa słowa, niemal wymienne przy pobieżnym przeczytaniu, a jednak każde z nich reguluje zupełnie inny aspekt logiki systemu.

Jeśli kiedykolwiek odesłałeś kod 401 w sytuacji, która wymagała faktycznie kodu 403, lub uruchomiłeś proces logowania i ogłosiłeś zakończenie autoryzacji, nie zajmując się w ogóle logiką uprawnień, to już napotkałeś problemy wynikające z pomieszania tych dwóch koncepcji. Rozwiążmy je raz na zawsze.

Autoryzacja: Weryfikacja tożsamości

Autoryzacja to etap, w którym system potwierdza, że użytkownik rzeczywiście jest tym, za kogo się podaje. Odpowiada na jedno pytanie: Czy ta tożsamość jest prawdziwa?

Jest to wydarzenie jednorazowe, zwykle występujące podczas logowania, i skutkuje powstaniem jakiegoś rodzaju uprawnień — sesji, tokena lub ciasteczka — które pozwalają reszcie aplikacji ufać żądającemu, bez konieczności ponownej weryfikacji hasła przy każdym żądaniu.

Typowe sposoby implementacji autoryzacji to:

  • Autoryzacja oparta na hasłach — podane dane są porównywane z wartością hasła spieczętowaną i przechowywaną w bazie danych
  • Autoryzacja oparta na tokenach (JWT) — podpisany token generowany po udanym logowaniu, który jest następnie sprawdzany przy każdym kolejnym żądaniu
  • OAuth 2.0 / OpenID Connect — weryfikacja tożsamości jest przekazywana zewnętrznemu dostawcy, takiemu jak Google lub GitHub
  • Klucze API — stała, tajna strona tekstowa identyfikująca określonego klienta lub usługę
  • Weryfikacja wielofaktorowa (MFA) — dodatkowy krok weryfikacji (kod jednorazowy, aplikacja autoryzująca) dodany na wierzch hasła
  • Oto jak wygląda standardowa wymiana danych przy autoryzacji:

    POST /api/login
    Content-Type: application/json
    
    {
      "email": "user@example.com",
      "password": "hashed_and_verified_serverside"
    }
    
    Response: 200 OK
    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
    }
    

    Serwer weryfikuje podane dane logowania i po ich potwierdzeniu zwraca token. Od tego momentu ten token służy jako dowód tożsamości użytkownika przy każdej prośbie:

    GET /api/profile
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    

    Middleware odpowiedzialny za weryfikację tego tokena zazwyczaj wygląda mniej więcej tak:

    function authenticate(req, res, next) {
      const token = req.headers.authorization?.split(' ')[1];
      if (!token) return res.status(401).json({ error: 'No token provided' });
    
      jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
        if (err) return res.status(401).json({ error: 'Invalid or expired token' });
        req.user = decoded;
        next();
      });
    }
    

    Zwróć uwagę na to, co ten middleware celowo pomija. W żadnym momencie nie decyduje o tym, co użytkownik ma prawo robić. Po prostu potwierdza, że token jest ważny, i dołącza zdekodowany payload do obiektu żądania. Nic więcej nie należy do zakresu zadań autoryzacji.

    Uprawnienia: Weryfikacja dostępu

    Uprawnienia wchodzą w grę dopiero po pomyślnej autoryzacji. Odpowiadają na zupełnie inne pytanie: Czy ta zweryfikowana tożsamość ma prawo wykonać tę konkretną czynność?

    Niektóre z powszechnych podejść do strukturyzowania uprawnień:

    • Kontrola dostępu oparta na rolach (RBAC) — uprawnienia są przypisywane rolom takim jak admin, editor lub viewer
  • Control dostępu oparty na atrybutach (ABAC) — decyzje dotyczące dostępu są obliczane na podstawie atrybutów takich jak dział, region, właściciel zasobu czy nawet pora dnia
  • Listy kontrolowania dostępu (ACL) — uprawnienia są wyraźnie definiowane dla każdego użytkownika i każdego zasobu
  • Zakresy (OAuth) — sam token koduje, jakie konkretne operacje są dozwolone, na przykład read:messages lub write:files
  • Zwykła weryfikacja uprawnień odbywa się po tym, jak żądanie przeszło już autoryzację:

    function authorize(requiredRole) {
      return (req, res, next) => {
        if (req.user.role !== requiredRole) {
          return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
      };
    }
    
    app.get('/api/admin/dashboard', authenticate, authorize('admin'), (req, res) => {
      res.json({ data: 'sensitive admin data' });
    });
    

    Są to dwie odrębne funkcje middleware obsługujące dwa różne zadania, wykonywane w ustalonej kolejności: authenticate określa, kim jest osoba wysyłająca żądanie, natomiast authorize decyduje, czy ta osoba ma prawo do dalszego działania. Żądanie może przejść pierwszą weryfikację, a mimo to zostać odrzucone przez drugą – przy czym takie odrzucenie nie ma nic wspólnego z tożsamością.

    Kody stanu, które faktycznie odzwierciedlają tę różnicę

    Tutaj teoretyczna różnica przeradza się w coś, co można dosłownie sprawdzić w kodzie.

    401 Unauthorized – nazwa jest myląca, ale ten kod faktycznie wskazuje na problem z autoryzacją. Oznacza to, że serwer nie otrzymał żadnych uprawnień lub te, które otrzymał (włączając token), są nieważne lub wygasłe. W tym momencie serwer nie ma żadnych pewnych informacji na temat osoby wysyłającej żądanie.

    403 Forbidden — ten kod wskazuje na problem z uprawnieniami. Serwer już ustalił, kim jest żądający; po prostu nie pozwala tej tożsamości wykonać żądanej czynności lub uzyskać dostęp do żądanego zasobu.

    // Authentication failure — identity unknown or invalid
    res.status(401).json({ error: 'Invalid credentials' });
    
    // Authorization failure — identity known, permission denied
    res.status(403).json({ error: 'You do not have access to this resource' });
    

    Wysyłanie kodu 401 w sytuacji, gdy rzeczywiście chodzi o problem z uprawnieniami, to błąd, który występuje ciągle. Polecane jest wtedy ponowne zalogowanie się klienta, mimo że prawdziwym problemem jest to, iż proces logowania przebiega bez zarzutu — konto po prostu nie posiada wymaganej roli. Sam ten błąd może przekształcić to, co powinno być szybkim rozwiązaniem problemu z uprawnieniami, w długą i bezskuteczną sesję debugowania po stronie klienta, polegającą na szukaniu błędu w logowaniu, którego w rzeczywistości nie ma.

    Gdzie to występuje w rzeczywistej architekturze

    Więźze JWT często łączą te elementy bez żadnego zamiaru ze strony twórców. Token ma służyć do przechowywania danych identyfikacyjnych — takich jak sub, email, iat i exp. To, czy powinien on również zawierać dane uprawnień, takie jak role lub scope, to decyzja projektowa zależna od konkretnego systemu. Umieszczanie informacji o rolach bezpośrednio w tokenie jest wygodne i pozwala uniknąć dodatkowych zapytań, ale wiąże się z poważnym kosztem: wszelkie zmiany w uprawnieniach użytkownika wejdą w życie dopiero po ponownym wydaniu tego tokena. To prawdziwa kompromisowa decyzja, a nie skrót bez żadnych negatywnych konsekwencji.

    {
      "sub": "user_12345",
      "email": "user@example.com",
      "role": "editor",
      "iat": 1710000000,
      "exp": 1710003600
    }
    

    Systemy oparte na sesjach po stronie serwera mają tendencję do oddzielania tych dwóch aspektów. Identyfikator sesji służy do autoryzacji, natomiast uprawnienia są pobierane na bieżąco z bazy danych przy każdej prośbie. Dzięki temu unika się problemu przestarzałych danych opisanego powyżej, ale kosztem dodatkowej wywołania bazy danych przy każdej prośbie.

    W architekturach mikrosług autoryzacja jest zazwyczaj scentralizowana – obsługiwana raz przez bramkę lub dostawcę tożsamości takiego jak Auth0, Keycloak czy AWS Cognito, który wydaje token. Z kolei autoryzacja jest zwykle pozostawiona poszczególnym usługom, które odczytują informacje zawarte w tym tokenie i stosują własne zasady. W rezultacie jeden event autoryzacyjny może być interpretowany inaczej przez dziesięć oddzielnych usług, z których każda samodzielnie decyduje, co dane identyfikator może robić.

    Zasada zapobiegająca większości błędów bezpieczeństwa

    Autoryzacja musi zostać przeprowadzona przed upoważnieniem, a te dwa procesy nigdy nie powinny być łączone w jedną wspólną kontrolę.

    // Correct: sequential, separate concerns
    app.post('/api/posts/:id/delete', authenticate, authorize('editor'), deleteHandler);
    
    // Wrong: conflating identity with permission in one step
    app.post('/api/posts/:id/delete', (req, res) => {
      if (req.headers.authorization === 'valid-token-and-also-admin') {
        // fragile, unscalable, and impossible to audit correctly
      }
    });
    

    System, który rygorystycznie weryfikuje tożsamość, ale pomija etap upoważniania, doskonale potwierdza tożsamość użytkownika, a następnie pozwala każdej potwierdzonej tożsamości robić to, co chce – więc kompromitacja jednego konta o niskich uprawnieniach oznacza w praktyce całkowity kompromitacja systemu. Z drugiej strony, system z dobrze dostrojonym upoważnieniem, działający nad słabą autoryzacją, ma odwrotny problem: starannie określone uprawnienia chronią drzwi, przez które każdy może swobodnie wejść bez żadnych ograniczeń.

    Oba warstwy muszą być solidne, stosowane we właściwej kolejności i traktowane jako odrębne kwestie. To właśnie stanowi całą różnicę — a gdy zostanie to prawidłowo odzwierciedlone w kodach stanu, łańcuchu middleware oraz sposobie projektowania tokenów, cała klasa błędów bezpieczeństwa po prostu znika.