Lab, spotkanie 2: Walidacja, logowanie, role i ochrona
🛠️ Laboratorium — Spotkanie 2 (3h)
Cztery części: dopinamy walidację danych, dodajemy prawdziwych użytkowników z hasłami, uwierzytelnianie przez JWT z rolami, i na koniec łatamy dziury bezpieczeństwa, które w tym momencie ma każdy niedbale napisany serwer.
Część I — Walidacja danych i obsługa błędów
🎯 Cel
Wzmocnić walidację ponad to, co Pydantic robi domyślnie (typy) — dodać reguły biznesowe (np. data_od musi być przed data_do) i ujednolicić sposób zgłaszania błędów w całym API.
1️⃣ Walidacja na poziomie pola — Field
from pydantic import BaseModel, Field
class PoligonCreate(BaseModel):
nazwa: str = Field(min_length=2, max_length=100)
lokalizacja: str = Field(min_length=2)
typ: str
dostepny: bool = TrueField pozwala dodać ograniczenia (długość, zakres liczbowy) bez pisania własnego kodu sprawdzającego — Pydantic zwróci 422 z czytelnym opisem, zanim Twoja funkcja w ogóle zobaczy dane.
2️⃣ Walidacja reguł biznesowych — model_validator
Reguła “data_od musi być przed data_do” dotyczy relacji między dwoma polami, nie pojedynczego pola — potrzebuje własnego walidatora:
from pydantic import model_validator
from datetime import datetime
class RezerwacjaCreate(BaseModel):
poligon_id: int
jednostka_id: int
data_od: str
data_do: str
@model_validator(mode="after")
def sprawdz_zakres_dat(self):
if datetime.fromisoformat(self.data_od) >= datetime.fromisoformat(self.data_do):
raise ValueError("data_od musi byc wczesniejsza niz data_do")
return selfTaki błąd Pydantic zamienia automatycznie na 422 — spójnie z resztą walidacji.
3️⃣ Spójne kody błędów w całym API
Ustal (i trzymaj się) konwencji:
| Sytuacja | Kod |
|---|---|
| Dane niepoprawnego typu/kształtu | 422 (automatycznie od Pydantic) |
| Zasób nie istnieje | 404 |
| Poprawne dane, ale konflikt ze stanem (duplikat, nakładający się termin) | 409 |
| Brak uprawnień | 403 |
| Brak uwierzytelnienia | 401 |
✅ Mini-zadanie części I
Dodaj model_validator sprawdzający, że data_od rezerwacji nie jest w przeszłości (porównaj z datetime.now()). Sprawdź, że próba rezerwacji poligonu na wczoraj zwraca 422 z czytelnym komunikatem.
Część II — Rejestracja i logowanie
🎯 Cel
Dodać prawdziwych użytkowników: tabelę w bazie, bezpieczne hashowanie haseł, i pierwsze dwa endpointy uwierzytelniania: /register i /login.
1️⃣ Tabela użytkowników
CREATE TABLE IF NOT EXISTS uzytkownicy (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
hash_hasla TEXT NOT NULL,
rola TEXT NOT NULL DEFAULT 'dowodca',
jednostka_id INTEGER REFERENCES jednostki(id)
);Konto dowódcy otrzymuje przypisanie do jednostki od prowadzącego/administratora. Nowe konto ma jednostka_id = NULL i do czasu przypisania nie może zarządzać rezerwacjami. Formularz rejestracji nie przyjmuje roli ani identyfikatora jednostki. Planista może obsługiwać wszystkie jednostki i nie wymaga własnego przypisania.
Jeśli tabela już istnieje, CREATE TABLE IF NOT EXISTS jej nie zmieni. Sprawdź PRAGMA table_info(uzytkownicy) i tylko gdy kolumny brakuje, wykonaj jednorazowo:
ALTER TABLE uzytkownicy ADD COLUMN jednostka_id INTEGER REFERENCES jednostki(id);W get_db() ze spotkania 1 włącz conn.execute("PRAGMA foreign_keys = ON") zaraz po otwarciu każdego połączenia, przed rozpoczęciem transakcji. Przypisanie wykonuje prowadzący na bazie ćwiczenia, używając rzeczywistych identyfikatorów:
with get_db() as conn:
if conn.execute("SELECT id FROM jednostki WHERE id = ?", (unit_id,)).fetchone() is None:
raise ValueError("Unknown unit")
cursor = conn.execute(
"UPDATE uzytkownicy SET jednostka_id = ? WHERE login = ?",
(unit_id, account_login),
)
if cursor.rowcount != 1:
raise ValueError("Unknown account")
conn.commit()unit_id i account_login to wartości wybrane przez prowadzącego dla istniejącej jednostki i konta. Przygotuj osobno konta Alfa i Bravo. Nie udostępniaj tego kodu jako publicznego endpointu samodzielnego przypisywania jednostki.
2️⃣ Hashowanie haseł — nigdy jawnym tekstem
pip install bcryptimport bcrypt
def zahashuj_haslo(haslo: str) -> str:
return bcrypt.hashpw(haslo.encode(), bcrypt.gensalt()).decode()
def sprawdz_haslo(haslo: str, hash_z_bazy: str) -> bool:
return bcrypt.checkpw(haslo.encode(), hash_z_bazy.encode())3️⃣ Endpoint /register
class RejestracjaRequest(BaseModel):
login: str
haslo: str = Field(min_length=8)
# UWAGA: celowo BRAK pola "rola" tutaj — patrz callout ponizej
@app.post("/register", status_code=201)
def rejestracja(dane: RejestracjaRequest):
with get_db() as conn:
istniejacy = conn.execute("SELECT id FROM uzytkownicy WHERE login = ?", (dane.login,)).fetchone()
if istniejacy:
raise HTTPException(status_code=409, detail="Login juz zajety")
cursor = conn.execute(
"INSERT INTO uzytkownicy (login, hash_hasla, rola) VALUES (?, ?, 'dowodca')",
(dane.login, zahashuj_haslo(dane.haslo)),
)
conn.commit()
return {"id": cursor.lastrowid, "login": dane.login, "rola": "dowodca"}Wersja tego endpointu, która przyjmuje rola: str wprost z żądania (dane.rola), pozwala dowolnemu nieuwierzytelnionemu użytkownikowi zarejestrować się od razu jako "planista" — czyli samodzielnie nadać sobie uprawnienia, które dopiero za chwilę (część IV) będziemy starannie chronić 403-ką. To nie teoretyczna dziura: to dokładnie ten sam błąd klasy mass assignment, co pozwolenie klientowi wysłać is_admin: true w formularzu rejestracji.
/register zawsze tworzy rolę dowodca, z góry, bez pytania klienta. Nadawanie roli planista to osobna operacja — patrz część III, gdzie budujemy do niej chroniony endpoint (wymagający, żeby robił to już istniejący planista).
🔹 Skąd bierze się PIERWSZY planista?
Skoro awans na planistę wymaga (część III) bycia już zalogowanym jako planista, ktoś musi być tym pierwszym — jajko i kura. W praktyce rozwiązuje się to jednym z dwóch sposobów:
- Seed przy starcie projektu — skrypt
seed.py(macie go już z laboratorium o bazie danych) wstawia jedno konto zrola = 'planista'bezpośrednio przez SQL, z pominięciem API. - Ręczna zmiana w bazie — prowadzący/administrator loguje się do
poligony.dbi robiUPDATE uzytkownicy SET rola = 'planista' WHERE login = ...dla konkretnego konta.
Żadna z tych dróg nie prowadzi przez publiczne, nieuwierzytelnione API — i tak właśnie powinno być.
4️⃣ Endpoint /login (na razie bez tokenu — dopiero część III)
class LoginRequest(BaseModel):
login: str
haslo: str
@app.post("/login")
def login(dane: LoginRequest):
with get_db() as conn:
uzytkownik = conn.execute("SELECT * FROM uzytkownicy WHERE login = ?", (dane.login,)).fetchone()
if not uzytkownik or not sprawdz_haslo(dane.haslo, uzytkownik["hash_hasla"]):
raise HTTPException(status_code=401, detail="Nieprawidlowy login lub haslo")
return {"komunikat": "Zalogowano", "rola": uzytkownik["rola"]}Zwróć uwagę: komunikat błędu jest celowo taki sam niezależnie od tego, czy zły jest login, czy hasło (“Nieprawidlowy login lub haslo”) — gdyby rozróżniać (“login nie istnieje” vs “złe hasło”), atakujący mógłby w ten sposób sprawdzać, które loginy istnieją w systemie.
✅ Mini-zadanie części II
Dodaj walidację, że login ma co najmniej 3 znaki i składa się tylko z liter/cyfr (np. przez Field(pattern=...) albo model_validator).
Część III — JWT i autoryzacja ról
🎯 Cel
Zamienić /login z części II na wydawanie prawdziwego tokenu JWT, i zabezpieczyć wybrane endpointy tak, żeby wymagały konkretnej roli (np. tylko planista może zatwierdzać rezerwacje).
1️⃣ Generowanie tokenu
pip install python-jose[cryptography]from jose import jwt
from jose.exceptions import JWTError
from datetime import datetime, timedelta
SECRET_KEY = "zmien-mnie-w-prawdziwym-projekcie" # docelowo: zmienna srodowiskowa!
ALGORITHM = "HS256"
def utworz_token(login: str, rola: str) -> str:
dane = {
"sub": login,
"rola": rola,
"exp": datetime.utcnow() + timedelta(hours=8),
}
return jwt.encode(dane, SECRET_KEY, algorithm=ALGORITHM)Zaktualizuj /login, żeby zwracał token zamiast samego komunikatu:
@app.post("/login")
def login(dane: LoginRequest):
# ... weryfikacja jak w czesci II ...
token = utworz_token(uzytkownik["login"], uzytkownik["rola"])
return {"access_token": token, "token_type": "bearer"}2️⃣ Weryfikacja tokenu — Depends
FastAPI ma wbudowany mechanizm wstrzykiwania zależności (Depends) — idealny do “za każdym razem zweryfikuj token, zanim wykonasz właściwą logikę endpointu”.
OAuth2PasswordBearer?
FastAPI ma gotowy schemat OAuth2PasswordBearer, ale zakłada, że logowanie przyjmuje dane w formacie OAuth2PasswordRequestForm — pola username/password wysłane jako formularz (application/x-www-form-urlencoded), nie nasz JSON z polami login/haslo. Użycie go “na skróty” (jak w niejednej gotowej dokumentacji w internecie) sprawiłoby, że przycisk “Authorize” w /docs wysyłałby dane w formacie, którego nasz /login nie rozumie. Zamiast dopasowywać cały nasz projekt do cudzej konwencji, czytamy nagłówek Authorization ręcznie — mniej “magii”, w pełni spójne z tym, co już mamy.
from fastapi import Depends, Header
def pobierz_biezacego_uzytkownika(authorization: str | None = Header(default=None)) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Brak lub nieprawidlowy naglowek Authorization")
token = authorization.removeprefix("Bearer ")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
account_login = payload.get("sub")
if not isinstance(account_login, str):
raise HTTPException(status_code=401, detail="Invalid token subject")
except JWTError:
raise HTTPException(status_code=401, detail="Nieprawidlowy lub wygasly token")
with get_db() as conn:
account = conn.execute(
"SELECT id, login, rola, jednostka_id FROM uzytkownicy WHERE login = ?",
(account_login,),
).fetchone()
if account is None:
raise HTTPException(status_code=401, detail="Account no longer exists")
return dict(account)Nagłówek jest opcjonalny na poziomie walidacji parametrów, aby nasz kod zwracał 401 również wtedy, gdy go brakuje. Wymagany Header(...) spowodowałby wcześniej błąd walidacji 422. Token potwierdza login, natomiast aktualną rolę i przypisanie do jednostki odczytujemy z bazy; nie ufamy starej roli zapisanej w tokenie.
Dowolny endpoint dostaje uwierzytelnionego użytkownika, dopisując jeden parametr:
@app.get("/rezerwacje")
def lista_rezerwacji(uzytkownik: dict = Depends(pobierz_biezacego_uzytkownika)):
...3️⃣ Autoryzacja — wymagaj konkretnej roli
def wymagaj_roli(wymagana_rola: str):
def sprawdz(uzytkownik: dict = Depends(pobierz_biezacego_uzytkownika)) -> dict:
if uzytkownik["rola"] != wymagana_rola:
raise HTTPException(status_code=403, detail="Brak uprawnien do tej operacji")
return uzytkownik
return sprawdz
@app.post("/rezerwacje/{rez_id}/zatwierdz")
def zatwierdz_rezerwacje(rez_id: int, uzytkownik: dict = Depends(wymagaj_roli("planista"))):
...wymagaj_roli("planista") to fabryka zależności — funkcja zwracająca funkcję, dopasowaną do konkretnej roli. Dzięki temu jeden mechanizm obsługuje dowolną liczbę ról bez powtarzania kodu.
Kontrola jednostki: rola nie wystarcza
Dodaj funkcję używaną przy każdej operacji na konkretnej jednostce:
def require_unit_access(account: dict, unit_id: int) -> None:
if account["rola"] == "planista":
return
if account["jednostka_id"] is None or account["jednostka_id"] != unit_id:
raise HTTPException(status_code=403, detail="Unit access denied")
@app.delete("/rezerwacje/{reservation_id}")
def delete_reservation(
reservation_id: int,
account: dict = Depends(pobierz_biezacego_uzytkownika),
):
with get_db() as conn:
conn.execute("BEGIN IMMEDIATE")
reservation = conn.execute(
"SELECT jednostka_id FROM rezerwacje WHERE id = ?", (reservation_id,),
).fetchone()
if reservation is None:
raise HTTPException(status_code=404, detail="Reservation not found")
require_unit_access(account, reservation["jednostka_id"])
conn.execute("DELETE FROM rezerwacje WHERE id = ?", (reservation_id,))
conn.commit()
return {"deleted": reservation_id}Zamknięcie połączenia przez get_db() cofa niezatwierdzoną transakcję w ścieżce odmowy. W przykładzie tworzenia rezerwacji ze spotkania 1 dodaj parametr account: dict = Depends(pobierz_biezacego_uzytkownika) oraz wywołaj require_unit_access(account, rez.jednostka_id) przed zapisem, zachowując atomowe sprawdzenie konfliktu. Przy zmianie rezerwacji sprawdź zarówno jej obecną, jak i proponowaną jednostkę — nie wystarczy sprawdzić danych przesłanych przez klienta.
Dla GET /rezerwacje planista odczytuje wszystkie wiersze. Dowódca bez przypisania otrzymuje 403, a przypisany dowódca odczytuje wyłącznie WHERE jednostka_id = ? z wartością pobraną z konta. Filtr przekazany w URL nie może poszerzyć tego zakresu. Zastąp wcześniejsze niezabezpieczone endpointy, zamiast rejestrować dwie funkcje dla tej samej metody i ścieżki.
Próba odbiorcza na świeżych danych: Alfa usuwa swoją rezerwację; Alfa nie może usunąć rezerwacji Bravo (403 i rekord nadal istnieje); planista może ją usunąć. Brak nagłówka daje 401. Konto bez jednostki dostaje 403 przy próbie rezerwacji. Przed każdą próbą odtwórz potrzebny rekord, aby wcześniejsze usunięcie nie zamieniło próby uprawnień w zwykłą odpowiedź 404.
4️⃣ Awans na planistę — dopiero teraz to możliwe
Mamy już wymagaj_roli, więc możemy dotrzymać obietnicy z części II: nadawanie roli planista to osobny, chroniony endpoint, nie pole w /register.
class AwansRequest(BaseModel):
login: str
@app.post("/uzytkownicy/awansuj")
def awansuj_na_planiste(
dane: AwansRequest,
uzytkownik: dict = Depends(wymagaj_roli("planista")), # tylko obecny planista moze awansowac
):
with get_db() as conn:
cursor = conn.execute(
"UPDATE uzytkownicy SET rola = 'planista' WHERE login = ?", (dane.login,)
)
conn.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Uzytkownik nie znaleziony")
return {"komunikat": f"{dane.login} jest teraz planista"}Zamknięty krąg: żeby kogoś awansować, sam musisz już być planistą — a pierwszy planista powstaje przez seed.py albo ręczny UPDATE, jak opisano w części II. Nikt nie zostaje planistą przez samo wypełnienie formularza rejestracji.
✅ Mini-zadanie części III
- Zabezpiecz
DELETE /poligony/{id}tak, żeby tylkoplanistamógł usuwać poligony —dowodcapróbujący to zrobić powinien dostać403. - Sprawdź ręcznie (przez seed/UPDATE) nadanie sobie roli planisty, zaloguj się, i przetestuj
/uzytkownicy/awansujna drugim, świeżo zarejestrowanym koncie.
Część IV — Ochrona przed atakami
🎯 Cel
Zamknąć trzy luki bezpieczeństwa, które są łatwe do przeoczenia w kodzie napisanym “na szybko”: brak walidacji nazw kolumn w sortowaniu, brak limitu żądań, i brak kontroli CORS.
1️⃣ SQL injection przez nazwę kolumny — luka, której nie łata ?
# PODATNE — mimo ze reszta zapytania jest parametryzowana!
@app.get("/rezerwacje")
def lista(sortuj: str = "data_od"):
return conn.execute(f"SELECT * FROM rezerwacje ORDER BY {sortuj}").fetchall()Placeholdery (?) działają tylko dla wartości, nie dla nazw kolumn/tabel — sortuj wstawiony przez f-string pozwala atakującemu wpisać dowolny SQL. Obrona: biała lista dozwolonych wartości.
DOZWOLONE_KOLUMNY = {"data_od", "data_do", "status"}
@app.get("/rezerwacje")
def lista(sortuj: str = "data_od"):
if sortuj not in DOZWOLONE_KOLUMNY:
raise HTTPException(status_code=400, detail="Nieprawidlowa kolumna sortowania")
return conn.execute(f"SELECT * FROM rezerwacje ORDER BY {sortuj}").fetchall()2️⃣ Rate limiting — ogranicz liczbę żądań
Bez limitu, ktoś może próbować zalogować się tysiące razy na sekundę (atak brute-force na hasło) albo po prostu zasypać serwer żądaniami.
pip install slowapifrom slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/login")
@limiter.limit("5/minute")
def login(request: Request, dane: LoginRequest):
...3️⃣ CORS — kto może wywoływać Twoje API z przeglądarki
Domyślnie przeglądarki blokują żądania JavaScript do innej domeny niż ta, z której strona została załadowana (Same-Origin Policy). Jeśli frontend (część II następnego spotkania) ma żyć na innej domenie/porcie niż backend, musisz jawnie zezwolić:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # tylko Twoj frontend, NIE "*" na produkcji
allow_methods=["*"],
allow_headers=["*"],
)"*" (dowolna domena) w połączeniu z endpointami wymagającymi logowania otwiera drzwi dla ataków CSRF-podobnych z dowolnej złośliwej strony. Zawsze wymieniaj konkretne, zaufane domeny.
✅ Mini-zadanie części IV
- Dodaj białą listę dozwolonych kolumn do wszystkich endpointów z parametrem
sortuj. - Dodaj rate limiting do
/login(max 5 prób/minutę z jednego adresu IP). - Skonfiguruj CORS dla
http://localhost:3000(przyda się w laboratorium o frontendzie).
✅ Zadanie na koniec spotkania
Zademonstruj prowadzącemu: rejestrację nowego użytkownika, logowanie zwracające token JWT, endpoint chroniony rolą (403 dla złej roli, sukces dla właściwej), oraz że próba SQL injection przez ?sortuj= jest odrzucana.
Do oddania: link do repozytorium / commit hash.