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

Лекция 10. Аутентификация в веб-приложениях

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


1. Введение

Практически любое серьёзное веб-приложение работает с пользователями: у каждого свой профиль, свои данные, свои права. Чтобы приложение могло отличить одного пользователя от другого и не дать постороннему доступ к чужим данным, нужны два связанных, но разных механизма — аутентификация и авторизация.

В этой лекции мы сосредоточимся на аутентификации: как приложение устанавливает, кто именно к нему обращается. Авторизацию (что пользователю разрешено делать) рассмотрим отдельно, но сначала чётко разведём эти понятия, потому что их очень часто путают.

Веб-приложению аутентификация нужна, чтобы защищать данные от чужого доступа, узнавать пользователя между запросами (HTTP сам по себе не хранит состояние), вести аудит действий и обеспечивать персонализацию.


2. Аутентификация vs авторизация

Это два разных этапа, которые почти всегда идут друг за другом.

Аутентификация (Authentication) — процесс проверки личности. Подтверждение того, что пользователь действительно тот, за кого себя выдаёт. Отвечает на вопрос: «Кто вы?»

Авторизация (Authorization) — процесс определения прав доступа: что этому пользователю разрешено делать. Отвечает на вопрос: «Что вам можно?»

ПризнакАутентификацияАвторизация
Вопрос«Кто вы?»«Что вам разрешено?»
Что проверяетЛичностьПрава и роли
Когда происходитПервойПосле аутентификации
ПримерВвод логина и пароляПроверка «является ли пользователь админом»
Типичный код ошибки401 Unauthorized403 Forbidden

Простая аналогия: аутентификация — это показать паспорт на входе в здание (подтвердить, кто вы). Авторизация — это проверка, пускают ли вас именно на седьмой этаж в серверную (есть ли у вас права).

Учётные данные и факторы

Учётные данные (credentials) — информация, которой пользователь подтверждает свою личность. Факторы аутентификации делят на три группы:

  1. Что вы знаете — пароль, PIN, ответ на секретный вопрос.
  2. Что у вас есть — телефон, аппаратный ключ (YubiKey), карта.
  3. Чем вы являетесь — отпечаток пальца, лицо, голос (биометрия).

Многофакторная аутентификация (MFA) — использование двух и более факторов из разных групп. Например, пароль + одноразовый код из приложения. Даже если пароль украдут, без второго фактора войти не получится.


3. Методы аутентификации

Главная проблема, которую решают все методы: HTTP — протокол без состояния (stateless). Сервер не помнит предыдущие запросы. Поэтому после входа нужно с каждым запросом как-то «напоминать» серверу, кто мы. Способы это сделать и отличают методы друг от друга.

Ключевое деление — stateful (состояние хранит сервер) против stateless (состояние «зашито» в сам токен у клиента).

3.1. Сессии и cookies (stateful)

Классический подход для серверных веб-приложений.

Как работает:

  1. Пользователь отправляет логин и пароль.
  2. Сервер проверяет учётные данные.
  3. Сервер создаёт сессию — запись в своём хранилище — и генерирует уникальный Session ID.
  4. Session ID отправляется клиенту в cookie (Set-Cookie).
  5. Браузер автоматически шлёт эту cookie в каждом следующем запросе.
  6. Сервер по 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 редко реализуют вручную — используют готовые библиотеки и провайдеров.

Сводная таблица методов

МетодСостояниеГде хранитсяОтзыв доступаТипичное применение
Сессии + cookiesstatefulна сервере (Session ID у клиента)мгновенныйклассические веб-сайты
JWT-токеныstatelessвнутри токена у клиентасложный (нужен blacklist)REST API, SPA, мобильные
Basic Authstatelessлогин/пароль в каждом запросенетвнутренние/простые 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/False

6. Аутентификация в FastAPI: регистрация и вход с JWT

Соберём полный, но компактный пример: регистрация, выдача JWT при входе и защита эндпоинта. FastAPI предоставляет OAuth2PasswordBearer — он указывает, откуда брать токен (Authorization: Bearer ...), и автоматически добавляет форму входа в Swagger UI.

6.1. Настройка и утилиты

from datetime import datetime, timedelta
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from passlib.context import CryptContext
from 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"]}

Полный флоу глазами клиента:

  1. POST /register — создать аккаунт (пароль хешируется и сохраняется).
  2. POST /token с логином и паролем — получить access_token.
  3. 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. Вопросы для самопроверки

  1. В чём принципиальная разница между аутентификацией и авторизации? Какие коды ответа HTTP им обычно соответствуют?
  2. Почему HTTP-протокол сам по себе не «помнит» вошедшего пользователя и как эту проблему решают?
  3. Чем stateful-аутентификация (сессии) отличается от stateless (JWT)? Назовите плюс и минус каждой.
  4. Что хранится на сервере при сессионной аутентификации, а что — у клиента?
  5. Из каких трёх частей состоит JWT? Что содержит каждая?
  6. Почему в payload JWT нельзя помещать конфиденциальные данные?
  7. Что произойдёт, если злоумышленник изменит payload токена, не зная SECRET_KEY?
  8. Почему нельзя хранить пароли в открытом виде и даже хешировать их через MD5?
  9. Что такое соль и от какой атаки она защищает?
  10. Опишите по шагам флоу регистрации и входа в FastAPI с выдачей JWT.
  11. Какую роль играет OAuth2PasswordBearer и зависимость get_current_user?
  12. В каких случаях вы выберете сессии, а в каких — JWT?