Kurs Python · Moduł 6: Async i FastAPI

JWT Authentication - tokeny dostępu

5 min czytania
W tej lekcji6

Witaj! Darwin z JWT authentication dla FastAPI!

Nasze Safari API przyjmuje już i zapisuje obserwacje, ale na razie każdy może dopisać do bazy, że widział na sawannie pingwina. Potrzebujemy bramy, przy której turysta pokazuje przepustkę, zanim coś zmieni.

JWT (JSON Web Tokens) to standard tokenów dostępu - zamiast session cookies używamy stateless tokens!

"Stateless" oznacza, że serwer nie przechowuje listy zalogowanych osób. Wszystko, czego potrzebuje, niesie sam token, a serwer sprawdza tylko jego podpis. Ceną jest to, że wydanego tokena nie da się łatwo unieważnić przed końcem jego ważności, dlatego tokeny dostępu żyją krótko.

Analogia Safari: JWT to jak elektroniczna przepustka safari - zawiera wszystkie info o turyście (ID, uprawnienia), jest podpisana cyfrowo (nie da się podrobić), i ma ważność (expires)!

Z czego składa się token

Token JWT to trzy fragmenty tekstu połączone kropkami: header.payload.signature. Header mówi, jakim algorytmem podpisano token. Payload zawiera dane, tak zwane claims, na przykład sub (kogo dotyczy token) i exp (do kiedy jest ważny). Signature to podpis wyliczony z dwóch pierwszych części i tajnego klucza. Header i payload są tylko zakodowane w Base64URL, a nie zaszyfrowane - każdy może je odczytać, więc nigdy nie wkładaj do tokena hasła. Podrobić można jedynie przepustkę bez pieczęci, a pieczęci bez klucza nikt nie odtworzy.

Instalacja

Instalujemy bibliotekę do tokenów, bibliotekę do hashowania haseł i python-multipart, którego FastAPI potrzebuje do odczytu formularzy:

1pip install python-jose[cryptography] passlib[bcrypt] python-multipart

Ten zestaw znajdziesz w wielu projektach. Aktualna dokumentacja FastAPI poleca jednak PyJWT i pwdlib z algorytmem Argon2 (pip install pyjwt "pwdlib[argon2]"), bo passlib od 2020 roku nie doczekał się nowego wydania. Pokażę różnicę pod koniec lekcji.

Hashowanie haseł

Hasła nigdy nie trafiają do bazy wprost. Zapisujemy hash - jednokierunkowy skrót, z którego nie da się odtworzyć oryginału. CryptContext z passlib ukrywa szczegóły algorytmu bcrypt:

1from passlib.context import CryptContext
2
3pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
4
5def hash_password(password: str) -> str:
6    return pwd_context.hash(password)
7
8def verify_password(plain_password: str, hashed_password: str) -> bool:
9    return pwd_context.verify(plain_password, hashed_password)

hash_password wywołasz raz, przy rejestracji. verify_password przy logowaniu hashuje podane hasło i porównuje wynik z zapisanym skrótem - samo hasło nigdzie nie zostaje.

Tworzenie JWT tokenów

Najpierw stałe: tajny klucz, algorytm i czas życia tokena. HS256 to podpis HMAC z SHA-256, w którym ten sam klucz podpisuje i weryfikuje:

1from jose import JWTError, jwt
2from datetime import datetime, timedelta, timezone
3
4SECRET_KEY = "your-secret-key-keep-it-secret"
5ALGORITHM = "HS256"
6ACCESS_TOKEN_EXPIRE_MINUTES = 30

Klucz z przykładu to tylko zaślepka. Prawdziwy wygeneruj poleceniem openssl rand -hex 32 i trzymaj w zmiennej środowiskowej, nigdy w repozytorium.

Teraz funkcja wystawiająca przepustkę. Kopiuje dane, dopisuje claim exp i podpisuje całość:

1def create_access_token(data: dict):
2    to_encode = data.copy()
3    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
4    to_encode.update({"exp": expire})
5    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
6    return encoded_jwt

data.copy() chroni słownik wywołującego przed zmianą. Czas liczymy przez datetime.now(timezone.utc). W starszym kodzie, także w niektórych ćwiczeniach, zobaczysz datetime.utcnow() - od Pythona 3.12 ta metoda jest oznaczona jako przestarzała, bo zwraca czas bez strefy.

Dekodowanie działa odwrotnie:

1def decode_token(token: str):
2    try:
3        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
4        return payload
5    except JWTError:
6        return None

jwt.decode sprawdza podpis i automatycznie odrzuca token, któremu minął exp. Lista algorithms jest ważna: mówi, które algorytmy akceptujesz, więc nikt nie przemyci tokena podpisanego innym. Każdy problem kończy się tu wartością None.

Login endpoint

Pora na bramę. OAuth2PasswordBearer wyciąga token z nagłówka Authorization: Bearer <token>, a tokenUrl wskazuje adres logowania, z którego korzysta przycisk "Authorize" w dokumentacji Swagger:

1from fastapi import FastAPI, Depends, HTTPException, status
2from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3
4app = FastAPI()
5
6oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

Sam ten obiekt niczego nie weryfikuje. Jeśli nagłówka brakuje, od razu odpowiada kodem 401, a jeśli jest, przekazuje dalej surowy tekst tokena.

Endpoint logowania przyjmuje formularz z polami username i password, dlatego potrzebny był python-multipart:

1@app.post("/token")
2async def login(form_data: OAuth2PasswordRequestForm = Depends()):
3    # Sprawdź użytkownika w bazie
4    user = authenticate_user(form_data.username, form_data.password)
5
6    if not user:
7        raise HTTPException(
8            status_code=status.HTTP_401_UNAUTHORIZED,
9            detail="Incorrect username or password"
10        )
11
12    access_token = create_access_token(data={"sub": user.username})
13    return {"access_token": access_token, "token_type": "bearer"}

Funkcja authenticate_user nie jest częścią FastAPI - to Twój kod, który szuka użytkownika w bazie i woła verify_password. Odpowiedź z access_token i token_type: "bearer" to format wymagany przez specyfikację OAuth2.

Na koniec zależność, która zamienia token na użytkownika, i chroniona trasa:

1async def get_current_user(token: str = Depends(oauth2_scheme)):
2    payload = decode_token(token)
3    if not payload:
4        raise HTTPException(status_code=401, detail="Invalid token")
5
6    username = payload.get("sub")
7    return {"username": username}
8
9@app.get("/protected")
10async def protected_route(current_user: dict = Depends(get_current_user)):
11    return {"message": f"Hello {current_user['username']}!"}

protected_route nie wie nic o tokenach: dostaje gotowego użytkownika przez Depends, dokładnie tak jak sesję bazy w poprzedniej lekcji. Dokumentacja FastAPI dodaje do takiego wyjątku 401 nagłówek headers={"WWW-Authenticate": "Bearer"}, zgodnie ze standardem - polecam robić tak samo.

Test:

Sprawdźmy wszystko z terminala: najpierw logowanie, potem wejście z tokenem.

1# Login
2curl -X POST http://localhost:8000/token \
3  -d "username=darwin&password=secret123"
4
5# Response: {"access_token":"eyJ...", "token_type":"bearer"}
6
7# Protected route
8curl http://localhost:8000/protected \
9  -H "Authorization: Bearer eyJ..."

Token w odpowiedzi zaczyna się od eyJ, bo tak wygląda zakodowany początek JSON-a {". Bez nagłówka Authorization trasa /protected zwróci 401.

Wersja z PyJWT

Jeśli pójdziesz za aktualną dokumentacją FastAPI, zmieniają się tylko importy i nazwa wyjątku:

1import jwt
2from jwt.exceptions import InvalidTokenError
3
4# jwt.encode(...) i jwt.decode(...) wywołujesz tak samo jak wyżej,
5# a w decode_token łapiesz InvalidTokenError zamiast JWTError

Reszta kodu, łącznie z OAuth2PasswordBearer i endpointami, zostaje bez zmian. W nowych projektach wybieraj PyJWT.

Następna lekcja: Testing z pytest! Sprawdzimy automatycznie, czy brama wpuszcza właściwych turystów.

Pamiętaj: JWT to przepustka z pieczęcią i datą ważności - każdy może ją przeczytać, ale nikt bez klucza jej nie podrobi.

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. Czym jest JWT (JSON Web Token)?

  2. 2. Z jakich trzech części składa się JWT?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Jak wygląda nagłówek Authorization z tokenem Bearer?

  • Układanie w pionie

    Ułóż funkcję tworzącą token JWT:

  • Klikanie w kolejności

    Ułóż dekodowanie tokenu JWT:

  • Edytor kodu

    Napisz endpoint GET /me wymagający tokena JWT i zwracający dane aktualnego użytkownika.

Przydatne artykuły