Регистрация и вход: аутентификация на Flask
Девятый урок курса Flask: модель User, хеширование паролей, маршруты register/login/logout и декоратор login_required. Демо хеширования sha256 с солью запускается прямо на странице.
Редакция Питоники
К этому уроку у блога есть база данных, модули и приличная структура. Но дверь открыта настежь: посты создаёт кто угодно, удалять чужое тоже может кто угодно, а понятия «мой пост» не существует вовсе. Добавим дверь с замком: страницу регистрации, вход по паролю, выход и защиту приватных страниц. И начнём с вопроса, на котором спотыкаются даже опытные: почему пароль нельзя хранить в базе как есть.
Сразу договоримся о словах. Аутентификация — проверка, кто вы: сверяем логин и пароль. Авторизация — проверка, что вам можно: удалять пост может только его автор. Слова похожи, механизмы разные, и в этом уроке мы соберём оба: сначала вход, затем проверку прав на удаление поста.
Как сделать авторизацию на Flask?
Модель User: таблица пользователей
Пользователи — это обычная таблица, которую мы добавим к базе из урока про SQLAlchemy. Единственная необычная деталь — название поля для пароля:
from datetime import datetime
from app import db
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<User {self.username}>"
Ограничение unique=True на username делает логин идентификатором: два одинаковых не пройдут. Поле password_hash сделали подлиннее (256 символов), потому что строка хеша из werkzeug длинная — в неё входят метод, параметры и соль.
Почему пароли не хранят «как есть»
Разберём сценарий, ради которого всё затевается. Злоумышленник получил копию базы — через дыру в соседнем сервисе, забытый бэкап, да просто скриншот админки в интернете. Если пароли лежат текстом, он входит в любой аккаунт. Хуже: люди переиспользуют пароли, так что он попробует те же связки в почте и соцсетях ваших пользователей. Хеш ломает всю схему: по нему пароль не восстановить, его можно только проверить — взять введённый пароль, прогнать через ту же функцию и сравнить результат.
Хешировать будем через hashlib — и, в отличие от серверных примеров, этот код выполняется прямо в браузере: запустите и посмотрите на результат:
import hashlib
password = "Parol-2024!"
salt = "7f3a9c1e" # фиксированная соль - для повторяемого вывода урока
raw = hashlib.sha256(password.encode("utf-8")).hexdigest()
salted = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
print("Без соли:", raw)
print("С солью: ", salted)
print("Длина хеша 64:", len(raw) == 64)
Без соли: 93cf75aaa2d7bd7e2faec367efd81f81f82042d56b88693067127d4f00042102 С солью: 927b1f0edd120af42bcad0e0162d22012c45c6db412366b0b97b55c876f41b7f Длина хеша 64: True
Функция принимает байты, поэтому строку мы сначала кодируем: password.encode("utf-8"). Метод hexdigest возвращает хеш шестнадцатеричной строкой — те самые 64 символа, под которые рассчитано поле password_hash. Добавление соли к паролю даёт совершенно другой хеш, и это не мелочь, а главная защита от готовых таблиц взлома — сейчас объясню.
import hashlib
def hash_password(password, salt):
return hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
def verify(password, salt, stored):
# хешируем введённый пароль и сравниваем с сохранённым
return hash_password(password, salt) == stored
salt = "7f3a9c1e"
stored = hash_password("Parol-2024!", salt)
print(verify("Parol-2024!", salt, stored))
print(verify("parol-2024!", salt, stored))
print(verify("Parol-2024!", "77aa55", stored))
# одинаковый пароль у двух пользователей, соли разные
print(hash_password("Parol-2024!", "aaaa1111")[:16])
print(hash_password("Parol-2024!", "bbbb2222")[:16])
True False False 2cd52240e65b882b 5108c7375d051636
Три наблюдения из вывода. Первое: верный пароль даёт True, пароль с другой буквой — False, угадать «похожий» пароль нельзя. Второе: та же строка в базе с другой солью перестаёт совпадать — соль участвует в хеше. Третье, самое важное: два пользователя с одинаковым паролем и разными солями получают разные хеши. Именно против общих таблиц хешей популярных паролей — радужных таблиц — это и работает: у взломщика нет одной таблицы на всех, для каждой соли считать придётся заново.
Для полноты картины — шкала хранения паролей от худшего к лучшему:
| Способ хранения | Что будет при утечке базы |
|---|---|
| Пароль текстом | Аккаунты скомпрометированы мгновенно, плюс повторное использование паролей |
| sha256 без соли | Популярные пароли вычисляются по готовым радужным таблицам за секунды |
| sha256 с солью | Готовые таблицы не работают, но быстрый алгоритм позволяет перебор миллиардами в секунду |
| scrypt/bcrypt/argon2 | Медленный алгоритм с солью делает перебор дорогим даже на видеокартах |
Так это выглядит во Flask: werkzeug
Werkzeug — набор утилит, на котором стоит Flask, — поставляется вместе с ним, ничего устанавливать не нужно. Две функции покрывают весь цикл:
from werkzeug.security import generate_password_hash, check_password_hash
h1 = generate_password_hash("qwerty42", method="pbkdf2:sha256")
h2 = generate_password_hash("qwerty42", method="pbkdf2:sha256")
print(h1[:21])
print(h1 == h2)
print(check_password_hash(h1, "qwerty42"))
print(check_password_hash(h1, "qwerty43"))
pbkdf2:sha256:600000$ False True False
Обратите внимание на False во второй строке: два вызова generate_password_hash для одного и того же пароля дают разные строки, потому что соль каждый раз генерируется заново и случайно. Это правильно и не мешает проверке: check_password_hash достаёт соль из самой строки и прогоняет через хеш введённый пароль. Хранить такие строки безопасно, сравнивать их напрямую — бессмысленно.
Маршрут регистрации: /register
Соберём auth-модуль. Блюпринт возьмём из прошлого урока, валидацию формы — руками, как в уроке про формы. GET показывает форму, POST принимает данные:
from flask import (Blueprint, render_template, request,
redirect, url_for, flash)
from werkzeug.security import generate_password_hash
from app import db
from models import User
bp = Blueprint("auth", __name__)
@bp.route("/register", methods=["GET", "POST"])
def register():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or len(password) < 8:
flash("Логин не пустой, пароль - от 8 символов")
return render_template("auth/register.html")
if User.query.filter_by(username=username).first():
flash("Такой логин уже занят")
return render_template("auth/register.html")
user = User(
username=username,
password_hash=generate_password_hash(password),
)
db.session.add(user)
db.session.commit()
flash("Регистрация успешна, теперь войдите")
return redirect(url_for("auth.login"))
return render_template("auth/register.html")
Здесь три проверки подряд, и каждая отвечает за свой класс проблем: пустой логин и короткий пароль — ошибки ввода, занятый логин — конфликт данных. Без проверки занятости дубли упал бы на db.session.commit() с IntegrityError — и пользователь увидел вместо внятного сообщения белую страницу с traceback. Сообщение об ошибке через flash показываем пользователю, но никогда не говорим, что именно неверно при входе — вернёмся к этому через минуту.
Отдельная тема — CSRF-защита POST-запросов: злоумышленник может подсунуть посетителю страницу, которая отправит форму регистрации на ваш сайт без его ведома. Стандартное решение — скрытое поле с токеном, который сервер выдаёт вместе с формой и проверяет при приёме; в шаблоне его даёт функция csrf_token(), а подключается она расширением типа Flask-WTF. На этом уроке мы её не строим, но знать про уязвимость стоит до того, как сайт выйдет в интернет.
sqlite> SELECT username, password_hash FROM user;
anna|scrypt:32768:8:1:f4c1d0e2b3a5$8e3f2b9c1d7a5e0f...
Вход и выход: /login и /logout
При входе находим пользователя по логину и сверяем пароль. Успех — запоминаем пользователя в session: словарь, который Flask подписывает секретным ключом и носит в куке браузера. Механику сессий мы подробно разбирали в уроке про сессии и cookies, здесь нам нужна одна строка: session["user_id"] = user.id.
from flask import session
from werkzeug.security import check_password_hash
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = User.query.filter_by(
username=request.form.get("username", "")
).first()
if user and check_password_hash(user.password_hash,
request.form.get("password", "")):
session["user_id"] = user.id
return redirect(url_for("blog.posts"))
flash("Неверный логин или пароль")
return render_template("auth/login.html")
@bp.route("/logout")
def logout():
session.pop("user_id", None)
return redirect(url_for("index"))
Одна деталь, на которую стоит посмотреть дважды: сообщение об ошибке одинаковое и для неверного логина, и для неверного пароля. Это не лень. Если писать «такого пользователя нет», сайт сам выдаст злоумышленнику список зарегистрированных логинов — а это половина взлома. Уточнение «неверный логин или пароль» оставляет обе версии равновероятными.
Что положить в сессию? Только id — число. Не пароль, конечно, и не весь объект User: объект, замороженный в сессии, устареет, если пользователь сменит данные, а достать актуального пользователя по id — одно обращение к базе. Я обычно добавляю хелпер current_user, который кеширует запрос внутри g — контейнера Flask, живущего ровно один HTTP-запрос:
from flask import g, session
from models import User
def current_user():
if session.get("user_id") is None:
return None
if "user" not in g:
g.user = User.query.get(session["user_id"])
return g.user
Декоратор login_required: защита страниц
Защитить страницу — значит перед вызовом view-функции проверить, вошёл ли пользователь. Для этого пишут один декоратор и вешают его на нужные маршруты:
from functools import wraps
from flask import session, redirect, url_for
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if session.get("user_id") is None:
return redirect(url_for("auth.login"))
return view(*args, **kwargs)
return wrapped
# применение: только для вошедших
@blog_bp.route("/posts/new", methods=["GET", "POST"])
@login_required
def new_post():
...
Строку @wraps(view) вычеркнуть нельзя, хотя кажется, что она ничего не делает. Декоратор подменяет исходную функцию своей — без @wraps все защищённые маршруты получили бы имя wrapped, и Flask упал бы при старте с конфликтом имён эндпоинтов.
Авторизация: удалять можно только свои посты
Вернёмся к терминам из начала урока. login_required проверил, что пользователь известен, — это аутентификация. Но вошедший пользователь всё ещё может стереть чужой пост, если не проверить права. У поста появится поле author_id, и тогда:
@blog_bp.route("/posts/<int:post_id>/delete", methods=["POST"])
@login_required
def delete_post(post_id):
post = Post.query.get_or_404(post_id)
if post.author_id != session["user_id"]:
flash("Удалять можно только свои посты")
return redirect(url_for("blog.post_detail", post_id=post_id))
db.session.delete(post)
db.session.commit()
return redirect(url_for("blog.posts"))
Чек-лист аутентификации и мостик к проекту
Соберём пройденное в порядок действий для любого сайта на Flask:
- Модель User с password_hash вместо password.
- Регистрация: валидация формы, проверка занятости логина, generate_password_hash.
- Вход: filter_by по логину, check_password_hash, session["user_id"] = user.id.
- Выход: session.pop("user_id", None).
- Приватные маршруты под login_required, права — проверкой author_id на сервере.
Этого минимума хватает, чтобы закрыть блог от чужих рук без сторонних библиотек. Когда проект вырастет, посмотрите на расширение Flask-Login — оно делает то же самое, но с готовыми current_user и Remember Me. Смысл механизма вы теперь понимаете, так что расширение будет не магией, а удобной обёрткой.
Впереди финал: в десятом уроке собираем всё пройденное в один проект — блог с постами, комментариями, авторством и пагинацией. Именно там ваш auth-модуль из этого урока займёт своё место в структуре, а страницы «только для своих» получат реальных читателей. Финал ближе, чем кажется: самоучитель Flask — карта курса от hello world до деплоя.
Сначала предскажи ответ в голове — это главный навык программиста.
import hashlib
def h(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
a = h("пароль")
b = h("пароль")
c = h("Пароль")
print(a == b, a == c)
session = {}
def login(uid):
session["user_id"] = uid
print("user_id" in session)
login(42)
print("user_id" in session, session["user_id"])
1. Чем аутентификация отличается от авторизации?
2. Почему в модели User поле password_hash, а не password?
3. Что вернут два вызова generate_password_hash для одного и того же пароля?
4. Что кладут в session после успешного входа?
5. Зачем @wraps(view) внутри декоратора login_required?
Соберите мини-аутентификацию на hashlib. Функция hash_password(password, salt) возвращает sha256-хеш строки salt + password (hexdigest). Функция verify(password, salt, stored) хеширует введённый пароль и сравнивает его с сохранённым хешем stored. Сохраните пароль Parol-2024! с солью 7f3a9c1e и проверьте три случая: верный пароль, пароль в другом регистре и верный пароль с другой солью.
Почему generate_password_hash при каждом вызове даёт разные строки?
Потому что соль генерируется заново и случайно при каждом вызове — в этом смысл защиты от радужных таблиц. Сравнивать хеши напрямую бессмысленно и не нужно: check_password_hash достаёт соль из самой строки, хеширует введённый пароль и сравнивает результат — поэтому проверка работает, хотя строки разные.
Как сделать регистрацию и вход пользователей на Flask?
Нужны три части: модель User с полем password_hash, маршруты register/login/logout и декоратор login_required. Пароль хешируется через generate_password_hash из werkzeug, при входе сверяется через check_password_hash, а после входа в session кладётся user.id.
Чем аутентификация отличается от авторизации?
Аутентификация отвечает на вопрос «кто вы» — это проверка логина и пароля. Авторизация — на вопрос «что вам можно»: например, удалять пост может только его автор. Сначала всегда аутентификация, потом проверка прав.
Почему нельзя хранить пароли в открытом виде?
При утечке базы пароли-текст попадают к злоумышленнику сразу, а хеш по себе пароль не восстанавливает. Хешируют через медленные алгоритмы (scrypt в werkzeug, bcrypt) с солью: это делает перебор дорогим, а радужные таблицы — бесполезными.
Что делает декоратор login_required во Flask?
Он оборачивает view-функцию и перед вызовом проверяет session['user_id']: если пользователя нет в сессии, возвращает redirect на страницу входа. Так помечают все приватные маршруты. Внутри декоратора обязателен @wraps, иначе Flask получит несколько эндпоинтов с именем wrapped и упадёт при старте.
Понравился урок? Сошлитесь на него
«Если писать «такого пользователя нет», сайт сам выдаст злоумышленнику список зарегистрированных логинов — а это половина взлома.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Flask · Урок 6
Сессии, куки и flash-сообщения во Flask: память о пользователе
Шестой урок курса Flask: session как словарь, куки set_cookie, секретный ключ и flash-сообщения. Учим приложение помнить пользователя между запросами.
Flask · Урок 8
Blueprint: организуем большое приложение на Flask
Восьмой урок курса Flask: Blueprint — мини-приложения внутри одного проекта. Регистрируем блюпринты, задаём префиксы адресов, разносим шаблоны по папкам и рефакторим блог из одного файла в модули.
Flask · Урок 10
Проект: блог на Flask с постами и комментариями
Финальный проект курса: блог на Flask с постами, комментариями и пагинацией. Модели, CRUD-маршруты, наследование шаблонов, деплой на PythonAnywhere и разбор каждого решения.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
FastAPI · Урок 8
Аутентификация по JWT: защищаем эндпоинты
Строим аутентификацию по JWT: хешируем пароли с солью, выдаём токен на /login и закрываем эндпоинты зависимостью get_current_user.
авторизация jwt fastapijwt аутентификация fastapi пример
FastAPI · Урок 10
Проект: REST API сервиса заметок с базой данных
Сквозной проект курса: сервис заметок с тегами, поиском, SQLite и JWT. Структура по файлам, контракты Pydantic, тесты и README — портфолио-проект за пару вечеров.
fastapi sqlite sqlalchemyавторизация jwt fastapi пример
Flask · Урок 5
База данных в Flask: SQLite и Flask-SQLAlchemy
Пятый урок курса Flask: данные переживают перезапуск сервера. Подключаем SQLite, описываем модели Flask-SQLAlchemy, проходим CRUD и собираем гостевую книгу, которая помнит всех гостей.
flask и база данных sqlalchemyflask sqlalchemy модель
Проверьте знания по Flask
В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по Flask: 20 задач