Менеджеры в Tirebase работают с несколькими сервисами каждый день, и каждый сервис требовал отдельный логин. Забыл пароль — меняй в каждом отдельно. Когда вопросы «не могу войти» стали отнимать больше времени, чем баги, я решил сделать единую авторизацию.
Исходная ситуация
Tirebase — платформа для шинного бизнеса. Ключевые сервисы, которым нужна авторизация:
- Search — поиск и подбор шин для менеджеров (Vue 3 SPA)
- Autoload Engine — управление маркетплейсами Avito/Ozon/WB (FastAPI)
- EVC — система событий и уведомлений (FastAPI)
- OCRM — CRM для б/у шин и Avito-объявлений (FastAPI)
- TS — POS-терминал шиномонтажа (Vue 3 SPA)
Каждый сервис имел свою авторизацию через ERP API, свои JWT-токены, свою логику валидации. Копирование auth-кода между сервисами — классика. Менеджеры вводили логин/пароль при переключении между Search и TS по несколько раз за смену.
Почему Keycloak
Я рассматривал три варианта:
- Auth0 — SaaS, удобный, но платный при масштабировании и данные хранятся у них
- Keycloak — self-hosted, открытый исходный код, полный набор фич
- Самописный auth-сервис — полный контроль, но это месяцы разработки
Keycloak победил по соотношению «функциональность / время настройки». SSO, OIDC, RBAC, социальные логины, 2FA, управление сессиями — всё из коробки. И он self-hosted — данные остаются у нас.
Docker Compose: запуск за 5 минут
version: '3.8'
services:
keycloak-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
volumes:
- keycloak_db_data:/var/lib/postgresql/data
networks:
- auth-network
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${KC_DB_PASSWORD}
KC_HOSTNAME: auth.example.com
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
ports:
- "8080:8080"
depends_on:
- keycloak-db
networks:
- auth-network
volumes:
keycloak_db_data:
networks:
auth-network:
driver: bridge
Важный момент: command: start, а не start-dev. Режим start-dev удобен для локальной разработки, но в продакшене он отключает кеширование и другие оптимизации. Я потерял полдня, отлаживая тормоза Keycloak, прежде чем заметил, что забыл убрать start-dev.
Настройка Realm и клиентов
После запуска — настройка через админ-панель Keycloak:
- Realm —
tirebase(изолированное пространство для платформы) - Роли —
admin,sales_manager,content_manager,tire_fitter - Клиенты — по одному на каждый сервис
Для FastAPI-сервисов (Autoload Engine, EVC, OCRM) я создал клиентов с типом confidential (backend-сервисы), для Vue SPA (Search, TS) — с типом public (фронтенд не может хранить секрет).
Можно настроить через UI, но для воспроизводимости я экспортировал конфигурацию realm в JSON и импортирую при деплое:
# Экспорт
docker exec keycloak /opt/keycloak/bin/kc.sh export \
--dir /opt/keycloak/data/export \
--realm tirebase
# Импорт при старте
docker run ... quay.io/keycloak/keycloak:24.0 \
start --import-realm \
--spi-import-dir=/opt/keycloak/data/import
FastAPI Middleware: валидация токенов
Центральная часть — middleware, который валидирует JWT-токены Keycloak в каждом сервисе. Я написал его один раз и подключил как общий пакет.
import httpx
from fastapi import Request, HTTPException
from fastapi.security import HTTPBearer
from jose import jwt, JWTError
from functools import lru_cache
class KeycloakAuth:
def __init__(self, server_url: str, realm: str, client_id: str):
self.server_url = server_url
self.realm = realm
self.client_id = client_id
self.jwks_url = (
f"{server_url}/realms/{realm}/protocol/openid-connect/certs"
)
self._jwks_client = None
@lru_cache(maxsize=1)
def _get_signing_keys(self) -> dict:
"""Кешируем JWKS — они меняются редко"""
response = httpx.get(self.jwks_url, timeout=10)
response.raise_for_status()
return response.json()
def decode_token(self, token: str) -> dict:
try:
jwks = self._get_signing_keys()
header = jwt.get_unverified_header(token)
# Находим нужный ключ по kid
key = None
for k in jwks["keys"]:
if k["kid"] == header["kid"]:
key = k
break
if not key:
# Инвалидируем кеш — возможно, ключи ротировались
self._get_signing_keys.cache_clear()
raise HTTPException(401, "Неизвестный ключ подписи")
payload = jwt.decode(
token,
key,
algorithms=["RS256"],
audience=self.client_id,
issuer=f"{self.server_url}/realms/{self.realm}",
)
return payload
except JWTError as e:
raise HTTPException(401, f"Невалидный токен: {e}")
def get_roles(self, payload: dict) -> list[str]:
"""Извлекаем роли из токена Keycloak"""
realm_roles = (
payload
.get("realm_access", {})
.get("roles", [])
)
client_roles = (
payload
.get("resource_access", {})
.get(self.client_id, {})
.get("roles", [])
)
return realm_roles + client_roles
Подключение к FastAPI через dependency injection:
from fastapi import Depends, FastAPI
app = FastAPI()
keycloak = KeycloakAuth(
server_url="https://auth.example.com",
realm="tirebase",
client_id="autoload-engine",
)
security = HTTPBearer()
async def get_current_user(credentials = Depends(security)) -> dict:
payload = keycloak.decode_token(credentials.credentials)
return {
"id": payload["sub"],
"email": payload.get("email"),
"roles": keycloak.get_roles(payload),
"name": payload.get("preferred_username"),
}
def require_role(role: str):
async def checker(user: dict = Depends(get_current_user)):
if role not in user["roles"]:
raise HTTPException(403, f"Требуется роль: {role}")
return user
return checker
@app.get("/api/tires")
async def get_tires(user: dict = Depends(get_current_user)):
# Любой авторизованный пользователь
return await fetch_tires()
@app.post("/api/feeds/generate")
async def generate_feeds(user: dict = Depends(require_role("content_manager"))):
# Только контент-менеджеры
return await trigger_feed_generation()
@app.delete("/api/products/{product_id}")
async def delete_product(user: dict = Depends(require_role("admin"))):
# Только администраторы
return await remove_product(product_id)
Социальные логины
Для внешних пользователей (клиенты, подрядчики, поставщики) я подключил вход через Google и Яндекс. В Keycloak это делается без единой строки кода — через Identity Providers в админке.
Для Яндекс ID, который не в стандартном списке Keycloak, пришлось настроить Generic OIDC Provider:
- Authorization URL:
https://oauth.yandex.ru/authorize - Token URL:
https://oauth.yandex.ru/token - User Info URL:
https://login.yandex.ru/info
После настройки — кнопка «Войти через Яндекс» появляется на форме логина Keycloak автоматически. Фронтенд ничего не знает о провайдерах — он просто редиректит на Keycloak, а тот разбирается.
Типичные ошибки, на которые я наступил
CORS. Keycloak по умолчанию не отдаёт CORS-заголовки для вашего фронтенда. В настройках клиента нужно явно указать Web Origins — https://app.example.com. Или + чтобы разрешить все Redirect URIs как origins.
Время жизни токена. По умолчанию access token живёт 5 минут. Для SPA это мало — пользователь открыл вкладку, отвлёкся на 10 минут, вернулся — разлогинен. Я увеличил до 15 минут и реализовал silent refresh через iframe (штатный механизм keycloak-js adapter). Не уверен, что 15 минут — оптимальный TTL, возможно стоит попробовать 10 минут с более агрессивным refresh, но пока работает нормально.
Ротация ключей. Keycloak периодически ротирует ключи подписи. Если middleware кеширует JWKS без инвалидации — после ротации все запросы начинают падать с 401. Отсюда cache_clear() в коде выше при неизвестном kid.
Realm export. Экспорт realm через kc.sh export не включает пользователей по умолчанию. Для полного бэкапа нужен флаг --users realm_file. Я узнал это, когда пришлось восстанавливать конфигурацию после неудачного обновления.
Результат
После миграции на Keycloak:
- Один логин для Search, Autoload Engine, EVC, OCRM и TS
- Одно место для управления пользователями и ролями
- Социальные логины без кода на нашей стороне
- Жалобы на авторизацию почти исчезли
- Код auth из каждого сервиса — удалён, осталась middleware на 60 строк
Настройка заняла один вечер для базового варианта, ещё пару дней на миграцию пользователей и настройку ролей. Ретроспективно, я бы сразу делал экспорт realm в JSON и автоматический импорт при деплое — первые недели настраивал вручную через UI и пару раз терял изменения при обновлении контейнера.
Если у вас больше двух сервисов и пользователи жалуются на повторные логины — Keycloak решает эту проблему. Self-hosted, бесплатный, с экосистемой, которую сложно переписать за разумное время.