Лекция 10. Аутентификация в веб-приложениях
Раздел 4. Безопасность и работа с пользователями Курс «Разработка интернет-приложений» (backend, Python/FastAPI) Продолжительность: ~1 ч 30 мин
1. Введение
Практически любое серьёзное веб-приложение работает с пользователями: у каждого свой профиль, свои данные, свои права. Чтобы приложение могло отличить одного пользователя от другого и не дать постороннему доступ к чужим данным, нужны два связанных, но разных механизма — аутентификация и авторизация.
В этой лекции мы сосредоточимся на аутентификации: как приложение устанавливает, кто именно к нему обращается. Авторизацию (что пользователю разрешено делать) рассмотрим отдельно, но сначала чётко разведём эти понятия, потому что их очень часто путают.
Веб-приложению аутентификация нужна, чтобы защищать данные от чужого доступа, узнавать пользователя между запросами (HTTP сам по себе не хранит состояние), вести аудит действий и обеспечивать персонализацию.
2. Аутентификация vs авторизация
Это два разных этапа, которые почти всегда идут друг за другом.
Аутентификация (Authentication) — процесс проверки личности. Подтверждение того, что пользователь действительно тот, за кого себя выдаёт. Отвечает на вопрос: «Кто вы?»
Авторизация (Authorization) — процесс определения прав доступа: что этому пользователю разрешено делать. Отвечает на вопрос: «Что вам можно?»
| Признак | Аутентификация | Авторизация |
|---|---|---|
| Вопрос | «Кто вы?» | «Что вам разрешено?» |
| Что проверяет | Личность | Права и роли |
| Когда происходит | Первой | После аутентификации |
| Пример | Ввод логина и пароля | Проверка «является ли пользователь админом» |
| Типичный код ошибки | 401 Unauthorized | 403 Forbidden |
Простая аналогия: аутентификация — это показать паспорт на входе в здание (подтвердить, кто вы). Авторизация — это проверка, пускают ли вас именно на седьмой этаж в серверную (есть ли у вас права).
Учётные данные и факторы
Учётные данные (credentials) — информация, которой пользователь подтверждает свою личность. Факторы аутентификации делят на три группы:
- Что вы знаете — пароль, PIN, ответ на секретный вопрос.
- Что у вас есть — телефон, аппаратный ключ (YubiKey), карта.
- Чем вы являетесь — отпечаток пальца, лицо, голос (биометрия).
Многофакторная аутентификация (MFA) — использование двух и более факторов из разных групп. Например, пароль + одноразовый код из приложения. Даже если пароль украдут, без второго фактора войти не получится.
3. Методы аутентификации
Главная проблема, которую решают все методы: HTTP — протокол без состояния (stateless). Сервер не помнит предыдущие запросы. Поэтому после входа нужно с каждым запросом как-то «напоминать» серверу, кто мы. Способы это сделать и отличают методы друг от друга.
Ключевое деление — stateful (состояние хранит сервер) против stateless (состояние «зашито» в сам токен у клиента).
3.1. Сессии и cookies (stateful)
Классический подход для серверных веб-приложений.
Как работает:
- Пользователь отправляет логин и пароль.
- Сервер проверяет учётные данные.
- Сервер создаёт сессию — запись в своём хранилище — и генерирует
уникальный
Session ID. Session IDотправляется клиенту в cookie (Set-Cookie).- Браузер автоматически шлёт эту cookie в каждом следующем запросе.
- Сервер по
Session IDнаходит сессию и понимает, кто пользователь.
Само состояние (кто пользователь, когда вошёл) живёт на сервере — в памяти, в Redis или в БД. У клиента только идентификатор-«ключ»:
import secrets
sessions: dict[str, dict] = {} # в продакшене — Redis или БД
def create_session(user_id: int) -> str: session_id = secrets.token_urlsafe(32) # криптостойкий случайный ключ sessions[session_id] = {"user_id": user_id} return session_idПлюсы: сервер полностью контролирует сессии, можно мгновенно «разлогинить» пользователя (удалить запись), данные не уходят клиенту.
Минусы: нужно хранить состояние; при нескольких серверах требуется общее хранилище сессий; уязвимость к CSRF (cookie шлётся автоматически).
Важные флаги cookie: HttpOnly (cookie недоступна из JavaScript — защита от
XSS-кражи), Secure (только по HTTPS), SameSite (защита от CSRF).
3.2. Токены и JWT (stateless)
Подход, доминирующий в API, SPA и мобильных приложениях.
Токен — строка, которая сама по себе подтверждает права пользователя. Сервер при входе выдаёт токен, а клиент шлёт его в заголовке:
Authorization: Bearer <token>Главное отличие от сессий: в случае JWT сервер ничего не хранит. Вся информация о пользователе лежит внутри токена, а его подлинность сервер проверяет по подписи. Это и есть stateless-аутентификация.
Различают несколько видов токенов:
- Access Token — короткоживущий, для доступа к ресурсам.
- Refresh Token — долгоживущий, для получения нового access-токена без повторного ввода пароля.
Подробно устройство JWT разберём в разделе 4.
Плюсы: не нужно хранить состояние; легко масштабируется (любой сервер проверит подпись); удобно для микросервисов.
Минусы: токен нельзя отозвать до истечения срока (нужен «чёрный список»); размер больше, чем у Session ID; критична защита секретного ключа.
3.3. Basic Auth и API-ключи
Basic Auth — простейший механизм HTTP. Клиент кодирует username:password
в Base64 и шлёт в заголовке:
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=Base64 — это не шифрование, а кодирование: пароль легко раскодировать. Поэтому Basic Auth допустим только поверх HTTPS и только для внутренних или очень простых систем. Пароль при этом летит в каждом запросе.
API-ключи — длинная случайная строка, выдаваемая приложению (а не человеку). Удобны для интеграций «сервис–сервис». Просты, но не несут информации о пользователе и требуют ручного управления (выдача, ротация, отзыв).
3.4. OAuth 2.0 (обзорно)
OAuth 2.0 — протокол делегированной авторизации. Он позволяет приложению получить ограниченный доступ к аккаунту пользователя на другом сервисе без передачи пароля. Это то, что стоит за кнопками «Войти через Google / GitHub».
Роли в OAuth 2.0:
- Resource Owner — сам пользователь (владелец данных).
- Client — приложение, которое хочет доступ.
- Authorization Server — выдаёт токены (например, сервер Google).
- Resource Server — API с защищёнными данными.
Самый распространённый сценарий — Authorization Code Flow: приложение
перенаправляет пользователя на сервер авторизации → пользователь логинится там и
даёт согласие → сервер возвращает приложению временный authorization code →
приложение меняет код на access token → с токеном обращается к API.
Важно: пароль пользователя приложению никогда не передаётся. На практике OAuth 2.0 редко реализуют вручную — используют готовые библиотеки и провайдеров.
Сводная таблица методов
| Метод | Состояние | Где хранится | Отзыв доступа | Типичное применение |
|---|---|---|---|---|
| Сессии + cookies | stateful | на сервере (Session ID у клиента) | мгновенный | классические веб-сайты |
| JWT-токены | stateless | внутри токена у клиента | сложный (нужен blacklist) | REST API, SPA, мобильные |
| Basic Auth | stateless | логин/пароль в каждом запросе | нет | внутренние/простые API |
| API-ключи | stateless | ключ у клиента | через отзыв ключа | интеграции сервис–сервис |
| OAuth 2.0 | зависит от реализации | токены провайдера | через провайдера | вход через Google/GitHub, SSO |
4. Устройство JWT
JWT (JSON Web Token) — открытый стандарт (RFC 7519) для самодостаточных токенов на базе JSON. Токен состоит из трёх частей, разделённых точками:
header.payload.signatureПример (части закодированы Base64URL):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwibmFtZSI6IkpvaG4ifQ.SflKxwRJSMeKKF2QT4fw...1. Header — алгоритм подписи и тип токена:
{ "alg": "HS256", "typ": "JWT" }2. Payload — полезная нагрузка с «утверждениями» (claims):
{ "sub": "1", "username": "admin", "iat": 1516239022, "exp": 1516242622}Стандартные поля (claims):
| Поле | Значение |
|---|---|
sub | идентификатор пользователя (subject) |
iss | кто выдал токен (issuer) |
aud | для кого предназначен (audience) |
iat | время выдачи (Unix timestamp) |
exp | время истечения |
jti | уникальный ID токена |
3. Signature — подпись, которая защищает токен от подделки:
HMACSHA256( base64url(header) + "." + base64url(payload), SECRET_KEY )Ключевая идея: header и payload не зашифрованы, а лишь закодированы — их
может прочитать кто угодно. Поэтому нельзя класть в payload секреты (пароли,
номера карт). Подпись же гарантирует целостность: если кто-то изменит payload,
подпись перестанет совпадать, и сервер отвергнет токен. Подделать подпись без
SECRET_KEY невозможно.
Риски JWT: утечка SECRET_KEY позволяет выпускать любые токены (храните ключ
в переменных окружения, не в коде); токен действует до exp и досрочно не
отзывается без доп. механизма (blacklist либо короткий срок жизни + refresh-токены).
5. Хранение паролей
Почему нельзя хранить пароли в открытом виде
Если базу данных украдут (а это случается регулярно), все пароли утекут мгновенно. Хуже того, люди используют одни и те же пароли на разных сайтах — утечка с одного сервиса компрометирует пользователя везде. Поэтому действует железное правило:
Никогда не храните пароли в открытом виде.
Хеширование
Хеш-функция превращает пароль в строку фиксированной длины необратимо: по хешу нельзя восстановить пароль. При входе мы хешируем введённый пароль и сравниваем хеши, а не сами пароли.
Но не любая хеш-функция подходит. Быстрые функции (MD5, SHA-256) плохи для паролей именно потому, что быстрые: злоумышленник перебирает миллиарды вариантов в секунду. Для паролей нужны адаптивные (намеренно медленные) алгоритмы: bcrypt, argon2, scrypt.
Соль (salt)
Соль — случайная строка, добавляемая к паролю перед хешированием. Зачем:
- Без соли одинаковые пароли дают одинаковые хеши — видно, у кого пароль совпадает, и работают заранее посчитанные таблицы (rainbow tables).
- С уникальной солью у каждого пользователя свой хеш, даже при одинаковом пароле.
Современные алгоритмы (bcrypt, argon2) генерируют соль автоматически и хранят её прямо внутри строки хеша — отдельно сохранять её не нужно.
| Алгоритм | Оценка |
|---|---|
| MD5, SHA-1 | ❌ устаревшие, небезопасные |
| SHA-256/512 | ⚠️ быстрые, для паролей не годятся |
| bcrypt | ✅ рекомендуется |
| argon2 | ✅ современный, победитель конкурса PHC |
| scrypt | ✅ требует много памяти, хороший выбор |
Пример с bcrypt
import bcrypt
def hash_password(password: str) -> str: salt = bcrypt.gensalt() # соль генерируется автоматически return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool: return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
stored = hash_password("my_secret_password") # при регистрацииverify_password("my_secret_password", stored) # True — при входеverify_password("wrong", stored) # FalseВ FastAPI-проектах часто используют библиотеку passlib, дающую единый интерфейс к разным алгоритмам (её и применим ниже в примере):
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")pwd_context.hash("secret") # хешированиеpwd_context.verify("secret", hashed) # проверка → True/False6. Аутентификация в FastAPI: регистрация и вход с JWT
Соберём полный, но компактный пример: регистрация, выдача JWT при входе и защита
эндпоинта. FastAPI предоставляет OAuth2PasswordBearer — он указывает, откуда
брать токен (Authorization: Bearer ...), и автоматически добавляет форму входа
в Swagger UI.
6.1. Настройка и утилиты
from datetime import datetime, timedeltaimport jwtfrom fastapi import Depends, FastAPI, HTTPException, statusfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom passlib.context import CryptContextfrom pydantic import BaseModel, EmailStr
app = FastAPI(title="Auth API")
SECRET_KEY = "change-me-in-production" # в реальности — из переменной окруженияALGORITHM = "HS256"ACCESS_TOKEN_EXPIRE_MINUTES = 30
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")users_db: dict[str, dict] = {} # упрощённая «БД»; в реальности — PostgreSQL
class UserCreate(BaseModel): username: str email: EmailStr password: str
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)
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)6.2. Регистрация
При регистрации мы сразу хешируем пароль и никогда не сохраняем его открытым. В ответе пароль (даже хешированный) тоже не отдаём.
@app.post("/register")def register(data: UserCreate): if data.username in users_db: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered", ) users_db[data.username] = { "username": data.username, "email": data.email, "hashed_password": hash_password(data.password), } return {"username": data.username, "email": data.email}6.3. Вход и выдача токена
OAuth2PasswordRequestForm принимает поля username и password из формы.
Проверяем пароль через verify_password и, если всё верно, выдаём JWT.
@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"}, ) access_token = create_access_token({"sub": user["username"]}) return {"access_token": access_token, "token_type": "bearer"}6.4. Проверка токена и защита эндпоинта
Функция get_current_user — зависимость (Depends), которая достаёт токен,
проверяет подпись и срок действия, находит пользователя. Любой эндпоинт,
объявивший её, становится защищённым.
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 jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise credentials_exception
user = users_db.get(username) if user is None: raise credentials_exception return user
@app.get("/users/me")def read_me(current_user: dict = Depends(get_current_user)): return {"username": current_user["username"], "email": current_user["email"]}Полный флоу глазами клиента:
POST /register— создать аккаунт (пароль хешируется и сохраняется).POST /tokenс логином и паролем — получитьaccess_token.GET /users/meс заголовкомAuthorization: Bearer <token>— сервер проверяет подпись и срок токена и возвращает данные пользователя.
7. Краткие итоги
- Аутентификация отвечает на вопрос «кто вы?», авторизация — «что вам
разрешено?». Это разные этапы; ошибки
401и403соответствуют им. - HTTP не хранит состояние, поэтому после входа клиент должен с каждым запросом подтверждать личность — сессией (cookie) или токеном (заголовок).
- Сессии — stateful: состояние на сервере, легко отозвать, но сложнее масштабировать. JWT — stateless: состояние в токене, легко масштабировать, но трудно отозвать досрочно.
- JWT состоит из
header.payload.signature. Payload не зашифрован (секреты туда класть нельзя), подпись гарантирует целостность и защищаетсяSECRET_KEY. - Basic Auth и API-ключи просты, но ограничены; OAuth 2.0 — делегированная авторизация без передачи пароля (вход через Google/GitHub).
- Пароли никогда не хранят в открытом виде. Используют адаптивные хеши (bcrypt, argon2) с солью, а не быстрые MD5/SHA.
- В FastAPI поток строится на
OAuth2PasswordBearer, хешировании через passlib и выдаче/проверке JWT через зависимостьget_current_user.
8. Вопросы для самопроверки
- В чём принципиальная разница между аутентификацией и авторизации? Какие коды ответа HTTP им обычно соответствуют?
- Почему HTTP-протокол сам по себе не «помнит» вошедшего пользователя и как эту проблему решают?
- Чем stateful-аутентификация (сессии) отличается от stateless (JWT)? Назовите плюс и минус каждой.
- Что хранится на сервере при сессионной аутентификации, а что — у клиента?
- Из каких трёх частей состоит JWT? Что содержит каждая?
- Почему в payload JWT нельзя помещать конфиденциальные данные?
- Что произойдёт, если злоумышленник изменит payload токена, не зная
SECRET_KEY? - Почему нельзя хранить пароли в открытом виде и даже хешировать их через MD5?
- Что такое соль и от какой атаки она защищает?
- Опишите по шагам флоу регистрации и входа в FastAPI с выдачей JWT.
- Какую роль играет
OAuth2PasswordBearerи зависимостьget_current_user? - В каких случаях вы выберете сессии, а в каких — JWT?