Перейти к содержимому

Практика 13. Практическая работа 13. Аутентификация и авторизация (JWT + RBAC)

Раздел 4. Безопасность и работа с пользователями Курс «Разработка интернет-приложений» (backend, Python/FastAPI)

Цель

  1. Реализовать регистрацию и вход пользователей в FastAPI.
  2. Научиться хешировать пароли через passlib/bcrypt и никогда не хранить их в открытом виде.
  3. Выдавать и проверять JWT (python-jose), используя OAuth2PasswordBearer.
  4. Написать зависимость get_current_user и защитить маршруты.
  5. Внедрить ролевую модель доступа (RBAC): разграничить права user и admin.
  6. Настроить CORS для безопасной работы с фронтендом.

Теория

  • Аутентификация отвечает на вопрос «кто вы?» (вход по логину и паролю), авторизация — «что вам разрешено?» (проверка роли). Им соответствуют коды 401 Unauthorized и 403 Forbidden.
  • HTTP не хранит состояние, поэтому после входа клиент с каждым запросом шлёт токен в заголовке Authorization: Bearer <token>.
  • JWT состоит из трёх частей header.payload.signature. Payload не зашифрован (секреты туда не кладём), а подпись по SECRET_KEY гарантирует целостность: подделать токен без ключа нельзя.
  • Пароли хранят только в виде адаптивного хеша (bcrypt) с автоматической солью, а не через быстрые MD5/SHA.
  • RBAC (Role-Based Access Control): пользователю назначается роль, а проверка прав на сервере выполняется через зависимости (Depends).
  • CORS разрешает доверенным «чужим» источникам обращаться к API из браузера.

Задание

Подготовка окружения

Окно терминала
mkdir fastapi-auth
cd fastapi-auth
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install fastapi uvicorn "passlib[bcrypt]" "python-jose[cryptography]" python-multipart email-validator

python-multipart нужен для формы входа OAuth2PasswordRequestForm, python-jose — для работы с JWT, passlib[bcrypt] — для хеширования паролей.

Задание 1. Каркас приложения, настройки и утилиты

Создайте main.py. Опишите константы, контекст хеширования, схему OAuth2 и «БД» в памяти.

from datetime import datetime, timedelta
from enum import Enum
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
from jose import jwt, JWTError
from pydantic import BaseModel, EmailStr
app = FastAPI(title="Auth API (Practice 13)")
SECRET_KEY = "change-me-in-production" # в реальности — из переменной окружения
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
users_db: dict[str, dict] = {} # упрощённая «БД»; в реальности — PostgreSQL
class Role(str, Enum):
ADMIN = "admin"
USER = "user"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)

Задание 2. Модели и регистрация пользователя

Опишите Pydantic-модели и эндпоинт POST /register. Пароль сразу хешируем и в ответе не возвращаем.

class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
username: str
email: EmailStr
role: Role
@app.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register(data: UserCreate):
if data.username in users_db:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = {
"username": data.username,
"email": data.email,
"hashed_password": hash_password(data.password),
"role": Role.USER, # по умолчанию обычный пользователь
}
users_db[data.username] = user
return user

Задание 3. Вход и выдача JWT

Реализуйте функцию создания токена и эндпоинт POST /token. Используйте OAuth2PasswordRequestForm (поля username, password).

def create_access_token(data: dict) -> str:
to_encode = data.copy()
to_encode["exp"] = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
user = users_db.get(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({"sub": user["username"], "role": user["role"].value})
return {"access_token": token, "token_type": "bearer"}

В payload кладём sub (имя пользователя) и role — роль понадобится для RBAC.

Задание 4. Зависимость get_current_user и защита маршрута

Напишите зависимость, которая достаёт токен, проверяет подпись и срок действия, находит пользователя. Защитите эндпоинт GET /users/me.

def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = users_db.get(username)
if user is None:
raise credentials_exception
return user
@app.get("/users/me", response_model=UserOut)
def read_me(current_user: dict = Depends(get_current_user)):
return current_user

Полный флоу: POST /registerPOST /token (получить токен) → GET /users/me с заголовком Authorization: Bearer <token>.

Задание 5. RBAC: доступ только для admin

Реализуйте фабрику зависимостей require_role и защищённый админский эндпоинт. Если роль не совпадает — 403 Forbidden.

def require_role(required: Role):
def checker(current_user: dict = Depends(get_current_user)) -> dict:
if current_user["role"] != required:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав",
)
return current_user
return checker
@app.get("/admin/users", response_model=list[UserOut])
def list_users(admin: dict = Depends(require_role(Role.ADMIN))):
# доступно только пользователям с ролью admin
return list(users_db.values())

Проверка прав вынесена из тела обработчика: чтобы попасть внутрь, нужен валидный JWT и роль admin.

Задание 6. Назначение роли admin (для проверки RBAC)

Чтобы протестировать админский маршрут, нужен пользователь-админ. Добавьте служебный эндпоинт повышения роли (в реальном проекте так не делают — роли назначает другой администратор или сидер БД).

@app.post("/admin/grant/{username}", response_model=UserOut)
def grant_admin(username: str, admin: dict = Depends(require_role(Role.ADMIN))):
user = users_db.get(username)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user["role"] = Role.ADMIN
return user

Для самого первого админа добавьте «сидер» при старте приложения:

@app.on_event("startup")
def seed_admin():
if "root" not in users_db:
users_db["root"] = {
"username": "root",
"email": "root@example.com",
"hashed_password": hash_password("root123"),
"role": Role.ADMIN,
}

Задание 7. Настройка CORS

Подключите CORSMiddleware, чтобы фронтенд с доверенного источника мог обращаться к API.

from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"], # конкретный источник фронтенда
allow_credentials=True,
allow_methods=["*"],
allow_headers=["Authorization", "Content-Type"],
)

Антипаттерн: allow_origins=["*"] вместе с allow_credentials=True — спецификация такую комбинацию запрещает. Всегда указывайте явный список доверенных источников.

Запуск и проверка

Окно терминала
uvicorn main:app --reload --host 127.0.0.1 --port 8000

Откройте Swagger UI: http://127.0.0.1:8000/docs. Проверьте сценарии:

  1. POST /register — зарегистрируйте обычного пользователя.
  2. POST /token — войдите, скопируйте access_token (в Swagger удобно нажать кнопку Authorize).
  3. GET /users/me — должен вернуть ваши данные; без токена — 401.
  4. GET /admin/users обычным пользователем — должен вернуть 403.
  5. Войдите как root / root123, повторите GET /admin/users — теперь 200.

Критерии оценки

  • 20% — регистрация с хешированием пароля (passlib/bcrypt); пароль не хранится и не возвращается в открытом виде.
  • 20% — вход POST /token с выдачей корректного JWT (python-jose); неверные данные дают 401.
  • 20% — зависимость get_current_user проверяет подпись и срок токена; защищённый GET /users/me работает.
  • 20% — RBAC: require_role/проверка роли, админский маршрут отдаёт 403 обычному пользователю и 200 админу.
  • 10% — корректно настроен CORS (явный список источников, без небезопасной комбинации * + credentials).
  • 10% — код запускается без ошибок, проверен через Swagger UI, все 5 сценариев воспроизводятся.

Вопросы для самопроверки

  1. Чем аутентификация отличается от авторизации? Какие коды HTTP (401/403) им соответствуют?
  2. Почему пароль хешируют через bcrypt, а не через MD5/SHA-256? Что такое соль и зачем она нужна?
  3. Из каких трёх частей состоит JWT? Почему в payload нельзя класть секреты?
  4. Что произойдёт, если изменить payload токена, не зная SECRET_KEY?
  5. Какую роль играет OAuth2PasswordBearer и что делает зависимость get_current_user?
  6. Зачем класть role в payload токена и как на её основе строится проверка прав?
  7. Что такое «фабрика зависимостей» require_role и почему проверку прав делают на сервере, а не только в интерфейсе?
  8. Зачем нужен CORS и почему комбинация allow_origins=["*"] с allow_credentials=True небезопасна?