Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 9 из 20 Средний 50 мин 150 XP

Регистрация и вход: аутентификация на Flask

Девятый урок курса Flask: модель User, хеширование паролей, маршруты register/login/logout и декоратор login_required. Демо хеширования sha256 с солью запускается прямо на странице.

Редакция Питоники

К этому уроку у блога есть база данных, модули и приличная структура. Но дверь открыта настежь: посты создаёт кто угодно, удалять чужое тоже может кто угодно, а понятия «мой пост» не существует вовсе. Добавим дверь с замком: страницу регистрации, вход по паролю, выход и защиту приватных страниц. И начнём с вопроса, на котором спотыкаются даже опытные: почему пароль нельзя хранить в базе как есть.

Сразу договоримся о словах. Аутентификация — проверка, кто вы: сверяем логин и пароль. Авторизация — проверка, что вам можно: удалять пост может только его автор. Слова похожи, механизмы разные, и в этом уроке мы соберём оба: сначала вход, затем проверку прав на удаление поста.

Как сделать авторизацию на Flask?

Модель User: таблица пользователей

Пользователи — это обычная таблица, которую мы добавим к базе из урока про SQLAlchemy. Единственная необычная деталь — название поля для пароля:

models.py — модель пользователя
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}>"
Поле называется password_hash, а не password: в таблице лежит хеш, сам пароль не хранится нигде. unique=True запретит второй анне занять логин первой.

Ограничение 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
В реальном приложении вместо sha256 с солью используют werkzeug (scrypt или pbkdf2) либо bcrypt — об этом сразу после демонстрации механики.

Функция принимает байты, поэтому строку мы сначала кодируем: 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
verify повторяет то, что делает check_password_hash из werkzeug: хеш не расшифровывает, а проверяет. Одна буква в регистре — и хеш не совпадёт.

Три наблюдения из вывода. Первое: верный пароль даёт True, пароль с другой буквой — False, угадать «похожий» пароль нельзя. Второе: та же строка в базе с другой солью перестаёт совпадать — соль участвует в хеше. Третье, самое важное: два пользователя с одинаковым паролем и разными солями получают разные хеши. Именно против общих таблиц хешей популярных паролей — радужных таблиц — это и работает: у взломщика нет одной таблицы на всех, для каждой соли считать придётся заново.

Для полноты картины — шкала хранения паролей от худшего к лучшему:

Способ храненияЧто будет при утечке базы
Пароль текстомАккаунты скомпрометированы мгновенно, плюс повторное использование паролей
sha256 без солиПопулярные пароли вычисляются по готовым радужным таблицам за секунды
sha256 с сольюГотовые таблицы не работают, но быстрый алгоритм позволяет перебор миллиардами в секунду
scrypt/bcrypt/argon2Медленный алгоритм с солью делает перебор дорогим даже на видеокартах

Так это выглядит во Flask: werkzeug

Werkzeug — набор утилит, на котором стоит Flask, — поставляется вместе с ним, ничего устанавливать не нужно. Две функции покрывают весь цикл:

generate_password_hash и check_password_hash
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
Серверный код в браузере не выполняется — вывод показан точно. Строка хеша читается как заголовок: метод, число итераций, затем случайная соль и сам хеш через $. Если вызвать generate_password_hash без method, свежий werkzeug (2.3+) выберет scrypt, и строка начнётся с scrypt:32768:8:1.

Обратите внимание на False во второй строке: два вызова generate_password_hash для одного и того же пароля дают разные строки, потому что соль каждый раз генерируется заново и случайно. Это правильно и не мешает проверке: check_password_hash достаёт соль из самой строки и прогоняет через хеш введённый пароль. Хранить такие строки безопасно, сравнивать их напрямую — бессмысленно.

Маршрут регистрации: /register

Соберём auth-модуль. Блюпринт возьмём из прошлого урока, валидацию формы — руками, как в уроке про формы. GET показывает форму, POST принимает данные:

auth/routes.py — регистрация
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")
Пароль хешируется в момент создания User — в базу попадает только хеш. Проверка длины пароля минимальная; в реальном проекте добавьте confirm-поле и капчу.

Здесь три проверки подряд, и каждая отвечает за свой класс проблем: пустой логин и короткий пароль — ошибки ввода, занятый логин — конфликт данных. Без проверки занятости дубли упал бы на 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.

auth/routes.py — вход и выход
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"))
session.pop("user_id", None) удаляет ключ, если он есть, и не ругается KeyError, если пользователя не было. Выход — это просто вычищенная сессия.

Одна деталь, на которую стоит посмотреть дважды: сообщение об ошибке одинаковое и для неверного логина, и для неверного пароля. Это не лень. Если писать «такого пользователя нет», сайт сам выдаст злоумышленнику список зарегистрированных логинов — а это половина взлома. Уточнение «неверный логин или пароль» оставляет обе версии равновероятными.

Что положить в сессию? Только id — число. Не пароль, конечно, и не весь объект User: объект, замороженный в сессии, устареет, если пользователь сменит данные, а достать актуального пользователя по id — одно обращение к базе. Я обычно добавляю хелпер current_user, который кеширует запрос внутри g — контейнера Flask, живущего ровно один HTTP-запрос:

helpers.py — текущий пользователь
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
g кеширует пользователя на время запроса: десять вызовов current_user в шаблоне дадут один запрос к базе, а не десять.

Декоратор login_required: защита страниц

Защитить страницу — значит перед вызовом view-функции проверить, вошёл ли пользователь. Для этого пишут один декоратор и вешают его на нужные маршруты:

auth/decorators.py — login_required
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():
    ...
Неавторизованного сразу отправляет на страницу входа — до того, как заработает код view-функции. Так защищают и создание постов, и личный кабинет, и всё, где нужен пользователь.

Строку @wraps(view) вычеркнуть нельзя, хотя кажется, что она ничего не делает. Декоратор подменяет исходную функцию своей — без @wraps все защищённые маршруты получили бы имя wrapped, и Flask упал бы при старте с конфликтом имён эндпоинтов.

Авторизация: удалять можно только свои посты

Вернёмся к терминам из начала урока. login_required проверил, что пользователь известен, — это аутентификация. Но вошедший пользователь всё ещё может стереть чужой пост, если не проверить права. У поста появится поле author_id, и тогда:

blog/routes.py — удаление с проверкой автора
@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"))
Проверка на сервере обязательна, даже если кнопка удаления спрятана в шаблоне: POST-запрос отправят и без кнопки. В шаблоне сравнивайте post.author_id == session['user_id'], чтобы спрятать кнопку у чужих постов, — но это удобство, а не защита.

Чек-лист аутентификации и мостик к проекту

Соберём пройденное в порядок действий для любого сайта на Flask:

  1. Модель User с password_hash вместо password.
  2. Регистрация: валидация формы, проверка занятости логина, generate_password_hash.
  3. Вход: filter_by по логину, check_password_hash, session["user_id"] = user.id.
  4. Выход: session.pop("user_id", None).
  5. Приватные маршруты под 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"])
Проверь себя
0 / 5

1. Чем аутентификация отличается от авторизации?

2. Почему в модели User поле password_hash, а не password?

3. Что вернут два вызова generate_password_hash для одного и того же пароля?

4. Что кладут в session после успешного входа?

5. Зачем @wraps(view) внутри декоратора login_required?

Карточки терминов
Запомнено: 0 / 6
Практика

Соберите мини-аутентификацию на hashlib. Функция hash_password(password, salt) возвращает sha256-хеш строки salt + password (hexdigest). Функция verify(password, salt, stored) хеширует введённый пароль и сравнивает его с сохранённым хешем stored. Сохраните пароль Parol-2024! с солью 7f3a9c1e и проверьте три случая: верный пароль, пароль в другом регистре и верный пароль с другой солью.

practice.py
Вопросы и ответы по уроку

Почему 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач