Перейти к контенту

FastAPI 12. Интеграция Starlette Admin

Сервис на FastAPI Иван Ашихмин 43

В этой статье узнаем про админ панели для FastAPI и интегрируем в проект библиотеку Starlette Admin.

FastAPI 12. Интеграция Starlette Admin
Сервис на FastAPI Иван Ашихмин 43

Следующим шагом в разработке бэкенда сервиса сокращения ссылок становится важный аспект — административная панель.

В обсуждениях FastAPI против Django довольно часто звучит аргумент вроде «зато у нас админка из коробки», и тут действительно есть разница подходов. Django — монолитный фреймворк со встроенными решениями, включая готовую админку. FastAPI же создавался как микрофреймворк, где по умолчанию есть только инструменты для построения API, а всё остальное добавляется при необходимости через сторонние библиотеки.

Тем не менее, экосистема вокруг FastAPI уже достаточно развилась, и вариантов админ-панелей более чем достаточно. Основные на сегодняшний день:

  • Starlette Admin — современная админка, построенная поверх Starlette (того же ASGI-фреймворка, на котором работает FastAPI). Гибкая, быстрая, поддерживает кастомные модели и авторизацию.
  • CRUDAdmin — административная панель с акцентом на автоматическую генерацию CRUD-интерфейсов. Требует отдельную базу для хранения администраторов и сильнее навязывает свою структуру.
  • SQLAdmin — лёгкое решение, завязанное на SQLAlchemy. Простое в установке, но менее гибкое по сравнению с Starlette Admin и рассчитано скорее на быстрый старт, чем на глубокую кастомизацию.

Можно ещё встретить упоминания FastAPI Admin и FastAdmin, но первый давно не обновлялся и фактически заброшен, а второй развивается медленно и пока не рекомендуется для продакшн-использования.

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


Почему Starlette Admin

Для этого проекта я решил выбрать Starlette Admin — библиотеку, которая предлагает хорошее сочетание производительности, гибкости и простоты интеграции.

SQLAdmin я уже использовал ранее в проекте Napkin Random Bot, причём в связке с Keycloak для авторизации. Работает неплохо, но хотелось попробовать что-то новое.

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

Starlette Admin в этом плане выглядит оптимальным решением: библиотека активно развивается, имеет подробную документацию и открытый репозиторий на GitHub, а главное — позволяет интегрировать админку в текущую архитектуру без создания «второй сущности пользователей».


Нюансы готовых решений

Готовые админ-панели не всегда покрывают все возможные требования проекта.

Одним достаточно просто получить доступ к данным в базе и, при необходимости, изменять записи. Другие хотят видеть дашборды, графики, быстрые действия по сущностям или, например, каскадные фильтры и массовые операции. И вот здесь возникает нюанс: не все решения позволяют легко адаптироваться под такие задачи. Иногда добавление даже небольшой кастомизации превращается в боль, особенно если библиотека навязывает собственный способ организации данных или ограничивает в расширении функционала.

Важно помнить, что админ-панель — это ещё одна зависимость внутри проекта. Она имеет собственную архитектуру, ограничения и уровень допускаемой кастомизации. Если библиотека не рассчитана на гибкие изменения, со временем она может начать мешать развитию проекта, а не помогать.

С другой стороны, создание полноценной админки с нуля — будь то HTML и JavaScript или с использованием фреймворков — требует времени, внимательного проектирования интерфейсов и, по сути, превращается в отдельный мини-фронтенд. В большинстве проектов, особенно на ранних этапах, это неоправданная роскошь.

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


План работ

В рамках этой части статьи мы реализуем следующее:

  1. Создадим отдельный пакет для админки, чтобы не смешивать логику панели управления с основной частью приложения.
  2. Добавим представления (view) для моделей User и Link, чтобы их можно было просматривать и редактировать через панель.
  3. Немного доработаем модуль auth, чтобы админка могла использовать текущую систему авторизации, а не создавать свою.
  4. Подключим авторизацию в админке на основе существующей логики, чтобы вход в админ-панель происходил через ту же систему, что и для API.

После этого у нас появится полноценная административная панель, интегрированная в проект без «вторых» сущностей пользователей и без отдельных баз данных только ради админки.

Открываем IDE — поехали!


Подготовка

Прежде чем подключать админку, внесём небольшие изменения в текущую структуру проекта. К счастью, правок немного, и они логичные — мы просто приведём инфраструктуру к состоянию, когда админка сможет использовать уже существующие ресурсы проекта.

Установка Starlette Admin

Для начала установим саму библиотеку:

poetry add starlette-admin

Если проект у вас не на Poetry — можно использовать uv add или pip install starlette-admin. Принцип тот же.

Получение движка БД

Админка работает напрямую с базой данных и для инициализации ей нужен движок БД (AsyncEngine). 
В нашем проекте в модуле db_dependency.py уже есть класс DBDependency, который создаёт движок и фабрику сессий, но наружу он сейчас отдаёт только сессии.

Добавим в класс новое property поле db_engine, которое будет возвращать существующий движок, а не создавать новый.

@property  
def db_engine(self) -> AsyncEngine:  
    return self._engine

Теперь из любого места в проекте мы можем получить тот самый движок, который уже используется в приложении.

Чтобы удобнее было вызывать его снаружи, добавим отдельную функцию:

def get_db_engine() -> AsyncEngine:  
    return DBDependency().db_engine

Так мы не создаём отдельный движок ради админки, а используем общий, что важно для согласованной работы транзакций и соединений с БД.

Полный код с учётом изменений:

from sqlalchemy.ext.asyncio import (
    AsyncEngine,
    AsyncSession,
    async_sessionmaker,
    create_async_engine,
)

from lkeep.core.settings import settings


class DBDependency:
    def __init__(self) -> None:
        self._engine = create_async_engine(url=settings.db_settings.db_url, echo=settings.db_settings.db_echo)
        self._session_factory = async_sessionmaker(bind=self._engine, expire_on_commit=False, autocommit=False)

    @property
    def db_session(self) -> async_sessionmaker[AsyncSession]:
        return self._session_factory

    @property
    def db_engine(self) -> AsyncEngine:
        return self._engine


def get_db_engine() -> AsyncEngine:
    return DBDependency().db_engine

Схема данных и CRUD-получения пользователя с ролью

Сейчас в классе UserManager есть метод get_user_by_email, который возвращает только id, email и hashed_password. Для API этого достаточно — при авторизации мы просто проверяем пароль и выдаём токен, не делая различий между обычным пользователем и администратором.

Однако для админки важно понимать, имеет ли пользователь права суперпользователя, иначе мы не сможем корректно разграничить доступ. Добавлять поле is_superuser в основной метод не хочется — он используется в логике API, и тащить туда админскую часть не очень чисто архитектурно.

Поэтому сделаем отдельный путь получения пользователя специально для админки.

Создаём отдельную схему

Открываем auth/schemas.py и добавляем схему GetUserForAdmin, унаследованную от GetUserWithIDAndEmail. Она повторяет готовую структуру, но расширяет её полем is_superuser.

class GetUserForAdmin(GetUserWithIDAndEmail):  
    is_superuser: bool

Добавляем метод в UserManager

Теперь в UserManager создадим отдельный метод — get_user_by_email_for_admin. Он похож на стандартный get_user_by_email, но дополнительно выбирает поле is_superuser и возвращает расширенную схему.

async def get_user_by_email_for_admin(self, email: str) -> GetUserForAdmin | None:  
    async with self.db.db_session() as session:  
        query = select(self.model.id, self.model.email, self.model.hashed_password, self.model.is_superuser).where(  
            self.model.email  email  
        )  

        result = await session.execute(query)  
        user = result.mappings().first()  

        if user:  
            return GetUserForAdmin(**user)  

        return None

Такой подход даёт нам чистое разделение обязанностей: API остаётся независимым от админской логики, а админка получает ровно то, что нужно.


Инициализация админки

Переходим от теории к практике — подключаем административную панель к приложению.

Создаём базу для админки

Внутри пакета apps создаём новый пакет admin. Это позволит изолировать всё, что касается административной панели, от остальной логики приложения.

Внутри apps/admin создаём файл admin_base.py. Именно в нём будет происходить инициализация админки.

Функция инициализации

В admin_base.py объявляем функцию setup_admin(app: FastAPI). Она будет принимать уже созданный экземпляр FastAPI и подключать к нему административную панель. Функция ничего не возвращает — она просто «подмешивает» админку в приложение.

Внутри создаём экземпляр Admin и указываем ему параметры:

  • engine — движок БД. Мы передаём уже существующий движок через get_db_engine(), а не создаём новый — благодаря этому админка работает в одном соединении с основной частью приложения.
  • title — название панели, которое будет отображаться в интерфейсе.
  • base_url — опционально. По умолчанию админка доступна по /admin, но при желании можно поменять, например, на /dashboard или /control.

Затем вызываем .mount_to(app) — это ключевой шаг, который регистрирует все необходимые маршруты и подключает панели, формы и статические ресурсы, необходимые для работы админки.

from fastapi import FastAPI  
from starlette_admin.contrib.sqla import Admin  

from lkeep.core.core_dependency.db_dependency import get_db_engine  


def setup_admin(app: FastAPI) -> None:  
    admin = Admin(engine=get_db_engine(), title="Lkeep Admin")  

    admin.mount_to(app=app)

Подключаем в main.py

Теперь осталось лишь один раз вызвать setup_admin() при запуске приложения.

Открываем main.py и после подключения middleware (app.add_middleware(...)) добавляем:

from lkeep.apps.admin.admin_base import setup_admin

setup_admin(app)

После этого запускаем приложение:

poetry run app

Переходим по адресу: http://localhost:8000/admin

Если всё подключено корректно — вы увидите интерфейс Starlette Admin. Пока он пустой, потому что мы ещё не объявили модели — этим займёмся дальше.

 


Представления моделей

Чтобы модель появилась в админке, недостаточно просто добавить её в проект — нужно явно описать, как эта модель должна отображаться. Именно для этого используются представления (views).

Представление — это класс, который определяет:

  • какие поля показывать в списке объектов;
  • какие поля доступны при создании и редактировании;
  • порядок сортировки;
  • фильтры и поиск;
  • дополнительные действия (например, "активировать", "деактивировать");
  • и даже то, как именно объект создаётся или сохраняется (это важно, если требуется хэширование пароля или привязка владельца).

Два способа описания представления

Есть два подхода:

Использовать стандартный ModelView

ModelView(<SQLAlchemy модель>, pydantic_model=<Pydantic-схема>)

Подходит, если модель простая, не требует кастомной логики, и достаточно базового CRUD-интерфейса.

Создать собственный класс-представление

class UserView(ModelView):
    ...

UserView(<SQLAlchemy модель>)

Такой подход используется, когда нужно тонко управлять поведением: менять валидацию, автоматически подставлять значения, хэшировать пароль перед сохранением и т. д.

Какой подход используем мы

В проекте Lkeep у нас как минимум два особых кейса:

  • Пользователю при создании нужно хэшировать пароль, а не сохранять его в открытом виде.
  • Для модели ссылки (Link) нужно автоматически подставлять владельца, чтобы ссылка была привязана к конкретному пользователю.

Поэтому стандартного ModelView будет недостаточно — нам нужно написать кастомные представления для моделей User и Link.

Представление модели User

Для начала создадим отдельный пакет для представлений.

Внутри пакета admin создаём новый пакет views
В нём создаём файл user_view.py, где и будет находиться представление для пользователей.

Определяем класс представления

Создаём класс UserView, унаследованный от ModelView.

Наследование даёт нам доступ ко всем стандартным возможностям Starlette Admin — отображению таблиц, форм, фильтрам, сортировке и действиям CRUD (создание, редактирование, удаление).

Теперь пропишем основные настройки класса.

Основные поля конфигурации представления

Внутри класса задаются статические атрибуты, которые управляют поведением интерфейса:

  • fields — список доступных полей. Здесь указываются те атрибуты модели, которые будут отображаться, а также кастомные поля, созданные специально для админки (как в нашем случае с полем password, которого нет в модели).
  • exclude_fields_from_edit и exclude_fields_from_create — эти списки позволяют скрывать поля, которые не должны быть изменяемыми. Например, created_at и updated_at заполняются автоматически, и нет смысла показывать их при создании или редактировании объекта.
  • sortable_fields — разрешённые поля для сортировки. Это ограничивает административный интерфейс и предотвращает попытку отсортировать по полю, по которому сортировка не имеет смысла или технически невозможна.
  • fields_default_sort — определяет начальный порядок отображения объектов. Если мы не зададим сортировку, Starlette Admin покажет записи в порядке их первичного ключа, что не всегда удобно. Мы сразу укажем, что хотим видеть свежие записи первыми.
  • searchable_fields — поля, по которым будет работать встроенный поиск. Администратор сможет ввести текст, и админка построит SQL-запрос с фильтрацией.
  • page_size — сколько записей показывать на одной странице.
  • page_size_options — список вариантов, которые можно выбрать в интерфейсе. Важный нюанс — значение -1 значит «показать все записи». Это удобно, если вам нужно сразу просмотреть все данные таблицы.

Код класса с полями:

from starlette_admin import PasswordField
from starlette_admin.contrib.sqla import ModelView


class UserView(ModelView):  
    fields = [  
        "id",  
        "email",  
        PasswordField(  
            name="password",  
            label="Password",  
            required=True,  
            exclude_from_list=True,  
            exclude_from_detail=True,  
            exclude_from_edit=True,  
        ),  
        "is_active",  
        "is_verified",  
        "is_superuser",  
        "created_at",  
        "updated_at",  
    ]  

    exclude_fields_from_edit = ["created_at", "updated_at"]  
    exclude_fields_from_create = ["created_at", "updated_at"]  

    sortable_fields = ["email", "created_at"]  
    fields_default_sort = [("created_at", True)]  
    searchable_fields = ["email"]  

    page_size = 20  
    page_size_options = [5, 10, 25, 50, -1]

Особый случай — поле password

Так как в нашей модели нет поля password, а есть только hashed_password, мы добавляем виртуальное поле с использованием PasswordField.

Starlette Admin подставит для него специальный интерфейс с полем ввода пароля и автоматически скроет его содержимое (как в обычных формах авторизации). Это поле не попадёт напрямую в БД, но мы сможем перехватить его в методе before_create и преобразовать в хэш.

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

В аргументы класса передаём следующие значения:

  • name - название поля: password.
  • label - отображаемое название поля: Password.
  • required - делаем его обязательным к вводу.
  • exclude_from_list, exclude_from_detail и exclude_from_edit - исключаем его из отображения в общем списке, в подробностях и в редактировании.

Определение направления стандартной сортировки.

Список fields_default_sort определяет, какие поля будут автоматически применяться для сортировки отображаемых объектов. Список принимает как просто строки, например, created_at для сортировки по дате создания, так и кортежи, где первое значение строка, а вторая булево значение, определяющее порядок: ("created_at", True).

Есть два варианта сортировки:

  • ASC - сортировка от старого к новому. Применяется по умолчанию или с указанием False.
  • DESC - сортировка от нового к старому. Применяется при указании True.

Переопределение создания объекта.

По умолчанию Starlette Admin при создании объекта просто берёт данные из формы и напрямую сохраняет их в модель. Для большинства простых сущностей это нормально, но в случае с пользователями такое поведение недопустимо — мы обязаны валидировать данные и хэшировать пароль вручную.

Для этого в ModelView есть специальный хук — before_create. Он вызывается перед сохранением объекта в базу и позволяет вмешаться в процесс.

Метод выглядит так:

async def before_create(self, request: Request, data: dict[str, Any], user: User):
    ...
  • request — полный HTTP-запрос (можно извлечь куки, сессию, IP-адрес, при желании даже залогировать действия).
  • data — словарь с полями, которые были введены в форме.
  • userещё не сохранённый экземпляр модели User, в который будут записаны значения.

Здесь важно понимать: data хранит то, что ввёл администратор, а user — это объект SQLAlchemy, который будет сохранён после выполнения всех хуков.

Шаг 1. Валидация email

Хотя в FastAPI мы уже валидируем email через Pydantic-схемы, админка обходит API-слой, поэтому валидацию нужно дублировать здесь вручную, чтобы не допустить сохранения некорректных данных.

Мы используем библиотеку email-validator, которая бросает исключение EmailSyntaxError, если строка не соответствует формату email. В случае ошибки вызываем FormValidationError — это не просто исключение, а сигнал для админки подсветить поле с ошибкой в интерфейсе, что делает UX гораздо приятнее.

async def before_create(self, request: Request, data: dict[str, Any], user: User) -> None:
    try:  
        validate_email(data["email"])  
    except EmailSyntaxError:  
        raise FormValidationError(errors={"email": "Invalid email"})

Шаг 2. Хэширование пароля

Данные формы приходят в виде словаря, и там есть ключ password, который не существует в модели — его нет в БД, он чисто форменный.

Мы:

  1. Инициализируем CryptContext.
  2. Извлекаем пароль через data.pop("password"). Используем pop специально — это удаляет поле password из словаря, чтобы Starlette Admin не пыталась сохранить его напрямую.
  3. Записываем захэшированное значение в user.hashed_password.

Таким образом, в БД попадает только хэш, а чистый пароль нигде не сохраняется.

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")  
user.hashed_password = pwd_context.hash(data.pop("password"))

Полный код класса:

from typing import Any  

from email_validator import EmailSyntaxError, validate_email  
from passlib.context import CryptContext  
from starlette.requests import Request  
from starlette_admin import PasswordField  
from starlette_admin.contrib.sqla import ModelView  
from starlette_admin.exceptions import FormValidationError  

from lkeep.database.models import User  


class UserView(ModelView):  
    fields = [  
        "id",  
        "email",  
        PasswordField(  
            name="password",  
            label="Password",  
            required=True,  
            exclude_from_list=True,  
            exclude_from_detail=True,  
            exclude_from_edit=True,  
        ),  
        "is_active",  
        "is_verified",  
        "is_superuser",  
        "created_at",  
        "updated_at",  
    ]  

    exclude_fields_from_edit = ["created_at", "updated_at"]  
    exclude_fields_from_create = ["created_at", "updated_at"]  

    sortable_fields = ["email", "created_at"]  
    fields_default_sort = [("created_at", True)]  
    searchable_fields = ["email"]  

    page_size = 20  
    page_size_options = [5, 10, 25, 50, -1]  

    async def before_create(self, request: Request, data: dict[str, Any], user: User) -> None:  
        try:  
            validate_email(data["email"])  
        except EmailSyntaxError:  
            raise FormValidationError(errors={"email": "Invalid email"})  

        pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")  
        user.hashed_password = pwd_context.hash(data.pop("password"))

Теперь добавим в админку вторую главную сущность — ссылки.

В пакете apps/admin/views создаём файл link_view.py. Внутри объявляем класс LinkViewView, унаследованный от ModelView.

Особенность: внешний ключ owner_id

Модель Link содержит поле owner_id — это внешний ключ на пользователя. Starlette Admin не отображает такие поля автоматически, если нет явного отношения (relationship) в модели SQLAlchemy.

Даже если добавить owner_id в список fields, админка попытается отрисовать обычное поле ввода, хотя это техническое поле и должно быть только для чтения.

Чтобы корректно отобразить его как текст, а не как редактор, мы используем StringField. Это виртуальное поле, которое просто выводит значение из модели без попытки его редактировать.

Если бы в модели Link была настроена связь relationship(User), можно было бы использовать HasOne, и тогда админка нарисовала бы выпадающий список с пользователями. Мы сознательно этого не делаем — в нашем сценарии ссылка всегда принадлежит текущему администратору.

fields = [
        "id",
        "full_link",
        "short_link",
        StringField("owner_id", label="owner_id", read_only=True),
    ]

Специальная логика при создании ссылки

По умолчанию Starlette Admin создаёт объект без связи с текущим пользователем, что нежелательно — владелец должен определяться автоматически, а не руками.

Для этого снова используется хук before_create. Он предоставляет доступ к:

  • request — где в поле request.state.user (мы добавим это в авторизации позже) будет храниться текущий авторизованный администратор;
  • data — данные формы;
  • link — новый объект Link, который ещё не сохранён.

В методе мы:

  1. Получаем текущего администратора: admin_user = request.state.user
  2. Присваиваем ссылке владельца через link.owner_id = admin_user["id"]

Это гарантирует, что каждая созданная ссылка будет привязана к тому администратору, кто её создал — без ручного выбора и риска ошибок.

Важно понимать
На данном этапе request.state.user ещё не существует — мы подключим его позже, когда будем реализовывать авторизацию в админке. Сейчас просто закладываем логику, а механизм авторизации добавим дальше.

Код класса:

class LinkViewView(ModelView):  
    fields = [  
        "id",  
        "full_link",  
        "short_link",  
        StringField("owner_id", label="owner_id", read_only=True),  
    ]  

    async def before_create(self, request: Request, data: dict[str, Any], link: Link):  
        admin_user = request.state.user  
        link.owner_id = admin_user["id"]

Подключение представлений

Теперь, когда у нас готовы классы UserView и LinkViewView, нужно подключить их к административной панели, чтобы Starlette Admin начала их учитывать при генерации интерфейса.

Для этого вернёмся в apps/admin/admin_base.py и найдём функцию setup_admin.

Между созданием экземпляра Admin и вызовом mount_to() необходимо зарегистрировать наши классы представлений через метод .add_view().

Важно: каждое представление привязывается к конкретной модели SQLAlchemy, поэтому мы передаём не только класс представления, но и соответствующую модель.

def setup_admin(app: FastAPI) -> None:  
    admin = Admin(engine=get_db_engine(), title="Lkeep Admin")  

    admin.add_view(UserView(User))  
    admin.add_view(LinkViewView(Link))  

    admin.mount_to(app=app)

Теперь можно снова запустить приложение и открыть http://localhost:8000/admin.

Если всё подключено правильно — в левой панели появятся наши модели с названиями Users и Links, готовые к работе.

 

Делаем пользователя администратором

У нас нет механизма создания заранее заготовленного администратора. В одной из будущих статей определённо рассмотрим этот момент. Сейчас, чтобы у нас был способ войти в админ панель после добавления авторизации, нужно сделать одного пользователя администратором.

Для этого можно выполнить одно из двух действий:

  1. Подключиться к БД напрямую, например, в PyCharm или DBeaver и заменить флаг is_admin у пользователя созданного при регистрации с False на True.
  2. Открыть модель Users в админке, выбрать пользователя и нажать "редактировать". На открывшейся странице переключить галочку is_supreuser и сохранить.

Любое из этих действий даст права администратора пользователю и на этапе авторизации не будет никаких проблем.


Админка по умолчанию не знает ничего о нашей системе авторизации. Starlette Admin предоставляет интерфейс AuthProvider, через который можно подключить любой механизм проверки пользователя — от куки до OAuth2. Мы как раз реализуем свой класс, который адаптирует существующую логику авторизации к требованиям админ-панели.

Создадим в пакете apps/admin новый файл — admin_auth.py.

В нём объявим класс AdminAuthProvider, унаследованный от AuthProvider из Starlette Admin. Этот класс будет отвечать за:

  • поиск пользователя по email,
  • проверку пароля,
  • проверку, является ли он суперпользователем,
  • выдачу доступа или отказ.

Конструктор класса

Мы уже реализовали в auth всё необходимое: есть UserManager для получения пользователя и AuthHandler для проверки пароля и генерации токенов. Используем существующие компоненты.

Передадим их в конструктор AdminAuthProvider.

def __init__(self, handler: AuthHandler, manager: UserManager):  
    super().__init__()  
    self.handler = handler  
    self.manager = manager

Таким образом админка остаётся интегрирована в текущую систему авторизации, а не живёт отдельной жизнью. Это важно — мы не создаём второй тип пользователей, не делаем отдельную таблицу администраторов, а используем поле is_superuser в основной модели User.

Метод is_authenticated

Этот метод вызывается Starlette Admin каждый раз, когда пользователь обращается к административной панели. Его задача — понять, авторизован ли пользователь, и дать или не дать доступ к интерфейсу.

По сути, это аналог нашего get_current_user, только адаптированный под формат, который ожидает админка.

Создаём асинхронный метод is_authenticated, принимающий request: Request и возвращающий bool.

В самом начале объявляем try-except блок.

В блоке try получаем токен из cookies через функцию get_token_from_cookies(request)
Если кука присутствует, функция вернёт токен. 
Далее этот токен передаём в метод decode_access_token из AuthHandler, чтобы получить расшифрованные данные.

В блоке except отлавливаем HTTPException, которая может возникнуть, если токен отсутствует или невалиден. 
Админка не умеет обрабатывать исключения этого типа, поэтому просто возвращаем False — это корректный способ сообщить, что пользователь не авторизован.

Из расшифрованного токена (decoded_token) извлекаем user_id и session_id
Эти данные нужны, чтобы убедиться, что токен действительно хранится в Redis (а значит, сессия активна).

После этого ещё одна проверка. В блоке if not вызываем у менеджера метод get_access_token для проверки, есть ли токен в Redis. Если его там нет, то сработает инверсия и внутри блока if возвращаем False.

Если все проверки прошли успешно, то дальше обращаясь к request.state.user присваиваем ему словарь с ключом id и session_id с соответствующими значениями. Ранее в представлении модели ссылок мы получали авторизованного администратора именно из этого состояния.

В конце возвращаем True, сигнализируя, что пользователь успешно прошёл аутентификацию.

Код метода:

async def is_authenticated(self, request: Request) -> bool:  
    try:  
        token = await get_token_from_cookies(request=request)  
        decoded_token = await self.handler.decode_access_token(token=token)  
    except HTTPException:  
        return False  

    user_id = str(decoded_token.get("user_id"))  
    session_id = str(decoded_token.get("session_id"))  

    if not await self.manager.get_access_token(user_id=user_id, session_id=session_id):  
        return False  

    request.state.user = {"id": user_id}  
    return True

Почему мы не возвращаем объект пользователя целиком? 
Админке достаточно знать, что пользователь существует и авторизован. Детальная проверка (is_superuser и др.) будет происходить в отдельном методе login, который мы добавим дальше. Однако, если вам нужно больше данных о пользователе, например, для представлений с доступом из request.state.user, можно тут реализовать получение объекта пользователя.

Метод login

Метод login отвечает за обработку входа пользователя в админку. Несмотря на то, что у нас уже реализован логин в пакете auth, мы не можем использовать его напрямую, потому что Starlette Admin ожидает свой формат обработки авторизации и самостоятельно управляет объектом Response. Поэтому мы адаптируем логику авторизации под механизмы админки, используя уже существующие инструменты AuthHandler и UserManager.

Создаём асинхронный метод login, принимающий следующие параметры:

  • email — строка, переданная из формы логина;
  • password — пароль пользователя;
  • remember_me — флаг "запомнить", передаваемый админкой (мы пока его игнорируем, но параметр обязателен по сигнатуре);
  • request — объект запроса;
  • response — объект ответа, в который мы вручную установим cookie с токеном.

Метод должен вернуть объект Response, который админка обработает дальше.

Разбор логики по шагам

  1. Валидируем входные данные
    Оборачиваем проверку в try-except, чтобы различать ошибку валидации и неверные учетные данные. 
    Создаём экземпляр схемы AuthUser, чтобы гарантировать корректность структуры данных.
    Если Pydantic поднимет ValidationError (например, email невалидный), мы перехватываем ошибку и бросаем LoginFailed — это исключение из Starlette Admin, оно корректно отобразит ошибку на форме входа, не ломая интерфейс.
  2. Получаем пользователя
    Через метод get_user_by_email_for_admin извлекаем пользователя из БД. 
    Напоминаю: этот метод использует расширенную схему, включающую is_superuser.
  3. Проверяем права и пароль В одном if объединяем три условия через or:
    • пользователя нет (exist_user is None);
    • он не является администратором (not exist_user.is_superuser);
    • пароль неверен (not await self.handler.verify_password(...)).
      Если хотя бы одно условие истинно — выбрасываем LoginFailed.
  4. Генерируем токен и сохраняем его
    Создаём token и session_id, затем вызываем store_access_token, чтобы привязать токен к пользователю и сохранить его в Redis — это нужно для дальнейшей проверки сессии.
  5. Устанавливаем cookie
    Именно здесь важен момент: админка сама не ставит cookie, нам нужно сделать это вручную через response.set_cookie(...)
    Мы передаём тот же ключ, что и в API — "Authorization", чтобы система авторизации была единой.
  6. Возвращаем response
    После установки cookie просто возвращаем объект response — Starlette Admin продолжит обработку автоматически.

Код метода:

async def login(  
    self, email: EmailStr, password: str, remember_me: bool, request: Request, response: Response  
) -> Response:  
    try:  
        auth_data = AuthUser(email=email, password=password)  
    except ValidationError:  
        raise LoginFailed(msg="Invalid email or password")  
    exist_user = await self.manager.get_user_by_email_for_admin(email=auth_data.email)  

    if (  
        exist_user is None  
        or not exist_user.is_superuser  
        or not await self.handler.verify_password(  
            hashed_password=exist_user.hashed_password, raw_password=auth_data.password  
        )  
    ):  
        raise LoginFailed(msg="Invalid credentials")  

    token, session_id = await self.handler.create_access_token(user_id=exist_user.id)  

    await self.manager.store_access_token(token=token, user_id=exist_user.id, session_id=session_id)  

    response.set_cookie(key="Authorization", value=token, httponly=True, max_age=settings.access_token_expire)  

    return response

Метод logout

Метод logout отвечает за корректный выход администратора из системы. Он должен сделать две вещи:

  1. Удалить cookie с токеном (Authorization), чтобы браузер больше его не отправлял;
  2. Отозвать токен в Redis, чтобы даже если кто-то попытается использовать старый токен вручную, он не прошёл проверку.

Создаём асинхронный метод logout, принимающий request и response, и возвращающий Response.

  • Сначала удаляем cookie через response.delete_cookie("Authorization");
  • Затем получаем пользователя из request.state.user;
  • И вызываем revoke_access_token, чтобы удалить активную сессию из Redis.

Важно: удаление cookie — это фронтовая часть выхода, а отзыв токена — бэкендовая. Убирать только cookie — недостаточно, иначе токен останется валидным до истечения TTL.

Код метода:

async def logout(self, request: Request, response: Response) -> Response:  
    response.delete_cookie("Authorization")  

    user = request.state.user  

    await self.manager.revoke_access_token(user_id=user["id"], session_id=user["session_id"])  

    return response

Метод get_admin_user

Этот метод вызывается Starlette Admin после успешной авторизации, чтобы получить объект текущего администратора.

Даже если в интерфейсе админки мы не используем эти данные напрямую, метод обязателен, иначе библиотека не сможет завершить цикл проверки доступа.

Создаём синхронный метод get_admin_user, принимает объект request, а возвращать будем нашу схему GetUserByID.

Важный момент: метод обязательно должен быть синхронным. Если сделать его async, Starlette Admin просто проигнорирует результат — потому, что метод не будет вызван корректно.

Мы возвращаем Pydantic-схему GetUserByID, в которую передаём данные из request.state.user.

Код метода:

def get_admin_user(self, request: Request) -> GetUserByID:  
    return GetUserByID(**request.state.user)

Функция get_admin_auth_provider и регистрация обработчика

Наш класс AdminAuthProvider принимает внешние зависимости — AuthHandler и UserManager
Так как Starlette Admin не использует систему зависимостей FastAPI (Depends), мы не можем просто объявить их в аргументах конструктора и рассчитывать на автоматическое внедрение.

Поэтому создаём функцию-фабрику, которая вручную инициализирует необходимые зависимости и возвращает готовый экземпляр AdminAuthProvider.

Для этого ниже, под классом создадим функцию get_admin_auth_provider, которая возвращает AdminAuthProvider.

Внутри функции в переменной manager создаём экземпляр класса UserManager, передав в него экземпляры классов DBDependency и RedisDependency.

Затем в возврате создаём экземпляр нашего класса AdminAuthProvider передав в него manager и экземпляр класса AuthHandler.

Код функции:

def get_admin_auth_provider() -> AdminAuthProvider:  
    manager = UserManager(db=DBDependency(), redis=RedisDependency())  
    return AdminAuthProvider(handler=AuthHandler(), manager=manager)

Теперь вернёмся в функцию setup_admin, где создаётся экземпляр Admin
Туда необходимо передать новый аргумент auth_provider, присвоив ему вызов get_admin_auth_provider().

Это будет выглядеть следующим образом:

def setup_admin(app: FastAPI) -> None:  
    admin = Admin(engine=get_db_engine(), title="Lkeep Admin", auth_provider=get_admin_auth_provider())  

    admin.add_view(UserView(User))  
    admin.add_view(LinkViewView(Link))  

    admin.mount_to(app=app)

Готово! Теперь админка будет запрашивать авторизацию и пускать только администраторов!

 


Заключение

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

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

Тем не менее, результат того стоит — теперь у нас удобная админ-панель, которая не нарушает общую концепцию проекта и работает через нашу систему пользователей, а не через встроенную упрощённую авторизацию.

Аватар автора

Автор

Иван Ашихмин

Программист, фрилансер и автор гайдов. Занимаюсь разработкой ботов, сайтов и не только.

Войдите, чтобы оставить комментарий.

Комментариев пока нет.