Мне кажется, что уже есть сотни разных статей на эту тему, но каждый раз мне чего-то не хватало. Поэтому я решил написать свою статью, в которой покажу, как я реализую авторизацию и аутентификацию в своих проектах. Это именно гайд: вы можете взять готовый код и адаптировать его под свои нужды. В рамках статьи будут использоваться Ory Hydra и Ory Kratos, Apache APISIX в качестве API Gateway и несколько микросервисов на Golang. Всё это будет работать в Docker, чтобы вы могли легко запустить и поиграться.
На теорию много времени тратить не буду, можете посмотреть несколько статей по ссылкам ниже:
Сначала я хотел показать всё в рамках Kubernetes, но потом решил, что это будет излишне сложно в рамках статьи, поэтому
покажу на примере простого docker-compose, чтобы каждый мог легко запустить.
В рамках статьи я иду на определённые упрощения, например, использую стандартные пароли, не использую https и т.д. Это сделано для того, чтобы вы могли легко запустить и протестировать систему. В реальных проектах, конечно же, так делать не нужно.
Архитектура
Если с аутентификацией всё более-менее понятно, то с авторизацией всё сложнее. Можно выделить несколько основных сценариев работы:
Используем простые, статичные роли, которые редко меняются. Например,
admin
,user
,guest
и т.д.Нужен больший контроль и динамическое управление доступом, например, разрешения на определенные действия для разных ролей. И делать все атомарно. Тогда у нас появляется дополнительный уровень - разрешения/права, которые назначаются в рамках микросервиса, а потом назначаются на конкретные роли. Здесь уже явно нужен отдельный сервис, как минимум для централизованного управления, но всё ещё не требуется специализированная система.
Нужна сложная система управления доступом, которая может включать в себя RBAC (Role-Based Access Control) или ABAC (Attribute-Based Access Control). Это уже сложные системы, которые требуют отдельного сервиса для управления доступом и могут быть избыточными для большинства проектов.
С третьим всё сложно, но есть готовые решения (например, Casbin, Ory Keto и т.д.), которые можно использовать. Правда, это не гарантия того, что установите и поедете. Мы в своё время испугались использовать Casbin и правильно сделали. Всё решилось гораздо проще.
А вот первое и второе мы разберём в рамках этой статьи. Для реализации первых двух вариантов, кажется, что проще всего использовать JWT (JSON Web Token). Но это не совсем так. JWT имеет ограничение, точнее не столько он, сколько информация, передаваемая в заголовке. Если правильно помню — NGINX/Apache имеют лимит
в 8кб на заголовок, а это значит, что мы не можем хранить слишком много информации в JWT. То есть если для первого варианта он однозначно подойдёт, то для второго варианта уже будет зависеть от того, насколько атомарно мы делим и
сколько у нас микросервисов. Потому что если у нас 100 микросервисов, в каждом из которых будет свой scope на каждую CRUD ручку (news:read, order:create и т.д.), то выйдет примерно 4.88 килобайт, что уже близко к лимиту, а у нас в заголовках не только JWT.
Поэтому для первого варианта мы будем использовать JWT, а для второго варианта — будем использовать opaque токены и обращаться в сервис для получения информации о пользователе и его разрешениях. Это позволит нам избежать проблем с размером
заголовка и упростит управление доступом. Но, как можно понять, это добавит некоторые накладные расходы, но мы их сгладим за счёт кэширования на время жизни токена на стороне API Gateway. Он будет отвечать за проверку токена и ролей.
В первом случае у нас будет следующий путь:
Пользователь проходит аутентификацию через отдельный сервис.
Сервис аутентификации выдаёт JWT токен, который содержит информацию о пользователе и его ролях.
Пользователь отправляет запросы к микросервисам, передавая JWT токен в заголовке Authorization.
Запрос проходит через API Gateway, который проверяет JWT токен и извлекает информацию о пользователе.
API Gateway проверяет, есть ли у пользователя нужная роль, с которой можно в микросервис, и перенаправляет запрос к нужному микросервису.
Микросервис выполняет действие и возвращает ответ. В рамках самого микросервиса проверки уже не делаем.
Во втором случае у нас будет похожий путь, но вместо JWT мы выдаём opaque токен, в API Gateway также проверяем роли после запроса к сервису аутентификации, а на микросервисах добавляем middleware, который будет проверять разрешения на ручках.
Давайте выберем и поднимем всю необходимую инфраструктуру, чтобы у нас была возможность протестировать оба варианта.
Система авторизации/аутентификации
В качестве системы мы можем написать свой сервис, но с учётом огромного количества готовых решений, я думаю, что лучше использовать готовое решение, которое можно легко интегрировать в нашу архитектуру.
Я думаю, что многие хоть раз слышали о Keycloak. Он надёжен, стабилен и имеет много
возможностей, но у него есть пара недостатков:
Это довольно большой комбайн, который может быть избыточным.
У него монолитная архитектура, что может усложнить масштабирование и поддержку. Но самое главное в этом — если вы не знаете Java, то вам будет сложно его настраивать и поддерживать. Даже банально сделать свою страницу входа — уже не просто.
Ок, мы понимаем, что нам нужно что-то простое и гибкое. В идеале — что-то, что можно легко развернуть и настроить, API-first. Стек должен быть доступным, чтобы иметь возможность, например, написать своего провайдера. Также одним из требований — развертывание в нашей инфраструктуре, а не в облаке. И тут остаётся много вариантов, но я вначале остановился на нескольких:
Ory Hydra / Ory Kratos — микросервисная архитектура с четким разделением ответственности. Написан на Go, хорошо документирован. Зрелый проект с консервативным подходом к релизам (2 релиза в год). Используется в OpenAPI Initiative, что говорит о надежности. 12к звезд на GitHub. Kratos управляет идентичностями и аутентификацией, Hydra — OAuth2/OIDC провайдер.
Casdoor — простой старт "из коробки" с готовыми React-компонентами. Поддерживает все популярные методы аутентификации, включая социальные сети. Написан на Go, легко расширяется кастомными провайдерами. 11к звезд на GitHub. Из минусов - частые релизы усложняют поддержку, медленное исправление уязвимостей, переусложненный UI для управления ролями. Команда сейчас фокусируется на SaaS-решении, что может повлиять на open-source версию.
Authelia — зрелое решение с сертификацией OpenID Connect. Сильное комьюнити (24к звезд), хорошая интеграция с reverse proxy (Nginx, Traefik).
В рамках статьи я буду использовать Ory Hydra/Kratos, просто потому что хочу его пощупать. Для этого и нужны статьи:)
Для продакшена каждый выбирает инструмент под свои задачи и свой стек. А эта статья поможет вам поэкспериментировать, прежде чем выбрать инструмент для своей системы.
Давайте поднимем Ory Kratos в docker-compose. Для этого создадим файл docker-compose.yml
:
docker-compose.yml
services:
# Migration service for Kratos
kratos-migrate:
image: oryd/kratos:v1.3.1
environment:
- DSN=postgres://kratos:secret@kratos-pg:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
volumes:
- type: bind
source: ./docker/kratos/configs
target: /etc/config/kratos
depends_on:
- kratos-pg
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
restart: on-failure
networks:
- intranet
# Postgres database for Kratos
kratos-pg:
image: postgres:17
environment:
- POSTGRES_USER=kratos
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=kratos
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U kratos" ]
networks:
- intranet
# Example selfservice UI for Kratos. Just a simple Node.js app that uses Kratos for authentication.
kratos-selfservice-ui-node:
image: oryd/kratos-selfservice-ui-node:v1.3.1
ports:
- "4455:3000"
environment:
- KRATOS_PUBLIC_URL=http://kratos:4433/
- KRATOS_BROWSER_URL=http://127.0.0.1:4433/
- COOKIE_SECRET=changeme
- CSRF_COOKIE_NAME=ory_csrf_ui
- CSRF_COOKIE_SECRET=changeme
networks:
- intranet
restart: on-failure
# Ory Kratos service
kratos:
depends_on:
- kratos-migrate
image: oryd/kratos:v1.3.1
ports:
- '4433:4433' # public
- '4434:4434' # admin
restart: unless-stopped
environment:
- DSN=postgres://kratos:secret@kratos-pg:5432/kratos?sslmode=disable&max_conns=20&max_idle_conns=4
- LOG_LEVEL=trace
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
volumes:
- type: bind
source: ./docker/kratos/configs
target: /etc/config/kratos
networks:
- intranet
# Mail service for testing email flows
mailslurper:
image: oryd/mailslurper:latest-smtps
ports:
- '4436:4436' # Email UI
networks:
- intranet
networks:
intranet:
После чего можем зайти в веб-интерфейс по адресу http://127.0.0.1:4455
и зарегистрировать нового пользователя.
Тут важно использовать именно 127.0.0.1
, а не localhost
. После того как зайдете под новым пользователем, можете увидеть информацию о пользователе, попробовать поменять и подключить 2FA.

Первый шаг сделан, теперь у нас есть сервис аутентификации, но для реализации авторизации нужен Oauth. Давайте добавим сервис в нашу систему. Для этого мы будем использовать Ory Hydra, который будет работать с Ory Kratos для аутентификации пользователей. Ory Hydra будет выступать в роли OAuth2 провайдера, который будет выдавать токены доступа. Это позволит нам централизованно управлять доступом к ресурсам и сервисам в нашей микросервисной архитектуре.
Добавим Ory Hydra в наш docker-compose.yml
:
docker-compose.yml
# Ory Hydra migration service
hydra-migrate:
image: oryd/hydra:v2.3.0
depends_on:
- hydra-pg
environment:
- DSN=postgres://hydra:secret@hydra-pg:5432/hydra?sslmode=disable
command: migrate -c /etc/config/hydra/hydra.yml sql up -e --yes
volumes:
- type: bind
source: ./docker/hydra/config
target: /etc/config/hydra
networks:
- intranet
restart: on-failure
# Ory Hydra Postgres database
hydra-pg:
image: postgres:17
environment:
- POSTGRES_USER=hydra
- POSTGRES_PASSWORD=secret
- POSTGRES_DB=hydra
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U hydra" ]
networks:
- intranet
# Ory Hydra service
hydra:
image: oryd/hydra:v2.3.0
depends_on:
- hydra-migrate
ports:
- "4444:4444" # Public
- "4445:4445" # Admin
environment:
- DSN=postgres://hydra:secret@hydra-pg:5432/hydra?sslmode=disable
volumes:
- type: bind
source: ./docker/hydra/config
target: /etc/config/hydra
command: serve -c /etc/config/hydra/hydra.yml all --dev
networks:
- intranet
restart: unless-stopped
Теперь Ory Hydra запущен и готов к работе. Можем легко проверить, если перейдем по адресу http://127.0.0.1:4444/health/ready
.
Ory из коробки поддерживает экспорт метрик в Prometheus и трейсы в Jaeger, поэтому вы можете легко интегрировать их. Если не знакомы, можете почитать цикл моих статей и воспользоваться готовым репозиторием - вам будет достаточно добавить конфигурацию для новых сервисов и все будет работать. В рамках этой статьи сюда углубляться не буду.
Как я создавал Observability для своих pet-проектов. Часть 1
Микросервисы
Теперь давайте создадим несколько микросервисов на Golang, в рамках которых будем проверять разрешения на определенные действия. Пока сделаем простые CRUD сервисы, которые будут отдавать нам определенное сообщение и все. Логику будем делать позже, только в рамках проверки разрешений.
У нас будут следующие условные микросервисы:
users
- сервис для работы с пользователями.products
- сервис для работы с продуктами.orders
- сервис для работы с заказами.
Все сервисы будут в папке cmd
, всю нашу "логику" будем писать в рамках одного main.go файла, что бы не усложнять. main.go
будет выглядеть следующим образом для каждого из сервисов:
main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
func main() {
r := mux.NewRouter()
service := os.Getenv("SERVICE_NAME")
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from %s service!", service)
}).Methods("GET")
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Create in %s service!", service)
}).Methods("POST")
r.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprintf(w, "Read %s with id %s!", service, vars["id"])
}).Methods("GET")
r.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprintf(w, "Update %s with id %s!", service, vars["id"])
}).Methods("PUT")
r.HandleFunc("/{id}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
fmt.Fprintf(w, "Delete %s with id %s!", service, vars["id"])
}).Methods("DELETE")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting %s service on port %s", service, port)
log.Fatal(http.ListenAndServe(":"+port, r))
}
Теперь давайте создадим простой Dockerfile
для каждого из сервисов, что бы мы могли их собрать и запустить в Docker:
FROM golang:1.24-alpine AS builder
WORKDIR /build
COPY ./go.mod ./go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o products cmd/products/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates && \
addgroup -S appgroup && adduser -S appuser -G appgroup \
COPY --from=builder /build/products /app/products
USER appuser
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["/app/products", "sync", "-d"]
И добавляем наши сервисы в docker-compose.yml
:
docker-compose.yml
users:
build:
context: .
dockerfile: users.Dockerfile
environment:
- SERVICE_NAME=users
- PORT=8080
ports:
- "8081:8080"
networks:
- intranet
products:
build:
context: .
dockerfile: products.Dockerfile
environment:
- SERVICE_NAME=products
- PORT=8080
ports:
- "8082:8080"
networks:
- intranet
orders:
build:
context: .
dockerfile: orders.Dockerfile
environment:
- SERVICE_NAME=orders
- PORT=8080
ports:
- "8083:8080"
networks:
- intranet
После чего запускаем и пробуем постучать на один из сервисов -http://127.0.1:8081/
. Получаем ответ "Hello from users service!". Теперь осталось настроить последний сервис - API Gateway, который будет интегрирован с Ory Hydra и Ory Kratos.
Настройка API Gateway
Теперь, когда у нас есть сервисы аутентификации и авторизации, давайте настроим API Gateway, который будет интегрирован с Ory Hydra и Ory Kratos. В качестве API Gateway чаще всего используют Kong, но мне хочется попробовать что-то новое, поэтому я выбрал Apache APISIX.
Добавляем его в наш docker-compose.yml
:
docker-compose.yml
etcd:
image: bitnami/etcd:3.5
environment:
- ALLOW_NONE_AUTHENTICATION=yes
- ETCD_ADVERTISE_CLIENT_URLS=http://0.0.0.0:2379
networks:
- intranet
apisix:
image: apache/apisix
depends_on:
- etcd
ports:
- "9080:9080" # HTTP endpoint
- "9180:9180" # Admin-API
volumes:
- ./docker/apisix/config.yaml:/usr/local/apisix/conf/config.yaml
environment:
- APISIX_ENABLE_ADMIN=true
- APISIX_ADMIN_KEY=supersecret
- APSIX_ETCD_HOST=http://etcd:2379
networks:
- intranet
apisix-dashboard:
image: apache/apisix-dashboard
depends_on:
- apisix
ports:
- "9000:9000"
volumes:
- ./docker/apisix_dashboard_conf/config.yaml:/usr/local/apisix-dashboard/conf/conf.yaml
environment:
- DEFAULT_APISIX_ADMIN_KEY=supersecret
- APISIX_LISTEN_ADDRESS=http://apisix:9091
networks:
- intranet
Делаем два конфиг файла для APISIX и APISIX Dashboard, что бы настроить их под себя.
Создадим файл docker/apisix/config.yaml
:
apisix:
enable_admin: true
admin_key:
- name: admin
key: supersecret
allow_admin:
- 0.0.0.0/0
deployment:
role: traditional
admin:
admin_listen:
port: 9180
allow_admin:
- 0.0.0.0/0
etcd:
host:
- "http://etcd:2379"
prefix: "/apisix"
timeout: 30
И файл docker/apisix_dashboard_conf/config.yaml
:
conf:
listen:
port: 9000
etcd:
endpoints:
- etcd:2379
authentication:
secert: secert
expire_time: 3600
users:
- username: admin
password: admin
Открываем веб-интерфейс APISIX Dashboard по адресу http://127.0.0.1:9000
, вводим логин и пароль admin
/admin
.

Создаем страницу для получения токена
kratos-selfservice-ui-node
- который мы подняли ранее, не умеет работать с Ory Hydra, он отвечает только за аутентификацию и регистрацию пользователей. Поэтому нам нужно создать страницу, которая будет работать с Ory Hydra и получать токен доступа. Для этого мы создадим простую страницу на Node.js, которая будет использовать Ory Hydra для получения токена доступа. Эта страница будет использоваться для получения токена доступа и перенаправления пользователя на страницу Kratos Selfservice UI для аутентификации. Создадим папку docker/hydra
и файл index.js
в ней:
JS
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const session = require('express-session');
const app = express();
const port = 3001;
// Settings - explicitly specify IPv4 addresses
const HYDRA_PUBLIC_URL = process.env.HYDRA_PUBLIC_URL || 'http://127.0.0.1:4444'; // For browser
const HYDRA_INTERNAL_URL = process.env.HYDRA_INTERNAL_URL || 'http://hydra:4444'; // For internal requests from container
const HYDRA_ADMIN_URL = process.env.HYDRA_ADMIN_URL || 'http://hydra:4445'; // IPv4 for admin API
const CLIENT_ID = process.env.CLIENT_ID || 'web';
const CLIENT_SECRET = process.env.CLIENT_SECRET || 'web-secret';
const REDIRECT_URI = process.env.REDIRECT_URI || 'http://127.0.0.1:3001/callback';
const APP_URL = process.env.APP_URL || 'http://127.0.0.1:3001';
console.log('App settings:');
console.log('HYDRA_PUBLIC_URL:', HYDRA_PUBLIC_URL);
console.log('HYDRA_INTERNAL_URL:', HYDRA_INTERNAL_URL);
console.log('HYDRA_ADMIN_URL:', HYDRA_ADMIN_URL);
console.log('CLIENT_ID:', CLIENT_ID);
console.log('REDIRECT_URI:', REDIRECT_URI);
console.log('APP_URL:', APP_URL);
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: true,
cookie: {
secure: false,
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 1000 // 1 hour
}
}));
app.use(express.urlencoded({extended: true}));
app.use(express.json());
// Middleware for debugging cookies and sessions
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`, {
sessionID: req.sessionID,
cookies: req.headers.cookie,
session: req.session
});
next();
});
// ============= OAuth2 Client part =============
// Main page
app.get('/', (req, res) => {
const token = req.session.token;
const loginChallenge = req.query.login_challenge;
const loginError = req.query.error;
// If there is a login_challenge, show the login form
if (loginChallenge) {
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Login - OAuth2</title>
<style>
body { font-family: Arial, sans-serif; max-width: 400px; margin: 100px auto; padding: 20px; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h2 { color: #333; margin-bottom: 20px; }
input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
button { width: 100%; background: #007bff; color: white; padding: 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
button:hover { background: #0056b3; }
.info { background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 20px; font-size: 14px; }
.error { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 4px; margin-bottom: 20px; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h2>Login</h2>
${loginError ? `<div class="error">${loginError}</div>` : ''}
<form method="POST" action="/auth/login">
<input type="hidden" name="challenge" value="${loginChallenge}">
<input type="email" name="email" placeholder="Email" value="test@example.com" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
`);
}
// Regular main page
let content = `
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Demo</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.container { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.button { background: #007bff; color: white; padding: 12px 24px; border: none; border-radius: 4px; cursor: pointer; }
.button:hover { background: #0056b3; }
.token-info { background: #f8f9fa; padding: 20px; margin-top: 20px; border-radius: 4px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
input { width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px; }
.success { background: #d4edda; color: #155724; padding: 15px; border-radius: 4px; margin: 10px 0; }
</style>
</head>
<body>
<div class="container">
<h1>OAuth2 Demo Application</h1>`;
if (token) {
// Decode JWT
let claims = {};
try {
const parts = token.access_token.split('.');
if (parts.length === 3) {
claims = JSON.parse(Buffer.from(parts[1], 'base64').toString());
}
} catch (e) {
// Opaque token
}
content += `
<div class="success">
<h3>✅ You are logged in!</h3>
</div>
<div class="token-info">
<p><strong>Access Token:</strong></p>
<pre>${token.access_token}</pre>
<p><strong>Token Type:</strong> ${token.token_type}</p>
<p><strong>Expires In:</strong> ${token.expires_in} seconds</p>
${token.refresh_token ? `<p><strong>Refresh Token:</strong></p><pre>${token.refresh_token}</pre>` : ''}
${claims.sub ? `
<hr>
<h4>Information from token:</h4>
<p><strong>User ID:</strong> ${claims.sub}</p>
<p><strong>Email:</strong> ${claims.ext.traits?.email || 'N/A'}</p>
<p><strong>Roles:</strong> ${JSON.stringify(claims.ext.traits?.roles || [])}</p>
<p><strong>Scope:</strong> ${JSON.stringify(claims.scp)}</p>
<p><strong>Issued At:</strong> ${new Date(claims.iat * 1000).toLocaleString()}</p>
<p><strong>Expires At:</strong> ${new Date(claims.exp * 1000).toLocaleString()}</p>
` : ''}
</div>
<form action="/logout" method="post" style="margin-top: 20px;">
<button type="submit" class="button" style="background: #dc3545;">Logout</button>
</form>`;
} else {
content += `
<p>This is a demo application for testing OAuth2 flow with Ory Hydra.</p>
<p>Click the button below to start the authorization process.</p>
<form action="/start-oauth" method="post">
<label>Scopes (separated by space):</label>
<input name="scope" value="openid offline users:read products:read orders:read" />
<button type="submit" class="button">Start OAuth2 Authorization</button>
</form>`;
}
content += `
</div>
</body>
</html>`;
res.send(content);
});
// Start OAuth2 flow
app.post('/start-oauth', (req, res) => {
const scope = req.body.scope || 'openid offline';
const state = crypto.randomBytes(16).toString('hex');
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
// Save in session
req.session.oauth = {state, verifier};
req.session.save((err) => {
if (err) {
console.error('Session save error:', err);
}
});
console.log('Starting OAuth flow:', {
state,
sessionID: req.sessionID,
session: req.session
});
// Redirect to Hydra
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scope,
state: state,
code_challenge: challenge,
code_challenge_method: 'S256'
});
const authUrl = `${HYDRA_PUBLIC_URL}/oauth2/auth?${params}`;
console.log('Redirecting to:', authUrl);
res.redirect(authUrl);
});
// OAuth2 callback
app.get('/callback', async (req, res) => {
const {code, state, error, error_description} = req.query;
console.log('Callback received:', {code: code?.substring(0, 10) + '...', state, error});
console.log('Session oauth:', req.session.oauth);
console.log('Session ID:', req.sessionID);
if (error) {
return res.send(`
<div style="max-width: 600px; margin: 50px auto; padding: 20px;">
<h2>Authorization Error</h2>
<p><strong>Error:</strong> ${error}</p>
<p><strong>Description:</strong> ${error_description || ''}</p>
<a href="/">← Back</a>
</div>
`);
}
if (!req.session.oauth) {
console.error('No OAuth session found');
return res.status(400).send(`
<div style="max-width: 600px; margin: 50px auto; padding: 20px;">
<h2>Session Error</h2>
<p>OAuth session not found. Please try again.</p>
<a href="/">← Back</a>
</div>
`);
}
if (req.session.oauth.state !== state) {
console.error('State mismatch:', {
expected: req.session.oauth.state,
received: state
});
return res.status(400).send(`
<div style="max-width: 600px; margin: 50px auto; padding: 20px;">
<h2>Security Error</h2>
<p>Invalid state parameter</p>
<p><small>Expected: ${req.session.oauth.state}</small></p>
<p><small>Received: ${state}</small></p>
<a href="/">← Back</a>
</div>
`);
}
try {
// Exchange code for tokens - use INTERNAL URL for server-to-server
const tokenResponse = await axios.post(
`${HYDRA_INTERNAL_URL}/oauth2/token`,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: req.session.oauth.verifier
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
req.session.token = tokenResponse.data;
delete req.session.oauth;
res.redirect('/');
} catch (error) {
console.error('Token exchange error:', error.response?.data || error);
res.status(500).send(`
<div style="max-width: 600px; margin: 50px auto; padding: 20px;">
<h2>Token Retrieval Error</h2>
<pre>${JSON.stringify(error.response?.data || error.message, null, 2)}</pre>
<a href="/">← Back</a>
</div>
`);
}
});
// ============= Login/Consent Provider part =============
// Login handler
app.post('/auth/login', async (req, res) => {
const {email, password, challenge} = req.body;
try {
// 1. Get login flow from Kratos
const flowResp = await axios.get('http://kratos:4433/self-service/login/api');
const flowId = flowResp.data.id;
// 2. Submit login credentials to Kratos
const kratosResponse = await axios.post(
`http://kratos:4433/self-service/login?flow=${flowId}`,
{
method: 'password',
identifier: email,
password: password
},
{
headers: {'Content-Type': 'application/json'}
}
);
// If login is successful, use the entered email as subject
const userId = email;
// Save Kratos identity traits in session for consent
if (kratosResponse.data && kratosResponse.data.session && kratosResponse.data.session.identity) {
req.session.userTraits = kratosResponse.data.session.identity.traits;
} else {
req.session.userTraits = {email};
}
// Accept login in Hydra
const acceptResponse = await axios.put(
`${HYDRA_ADMIN_URL}/admin/oauth2/auth/requests/login/accept?login_challenge=${challenge}`,
{
subject: userId,
remember: true,
remember_for: 3600,
acr: '0'
}
);
return res.redirect(acceptResponse.data.redirect_to);
} catch (error) {
let errorMsg = 'Login failed';
if (error.response && error.response.data) {
if (typeof error.response.data === 'string') {
errorMsg = error.response.data;
} else if (error.response.data.error && error.response.data.error.message) {
errorMsg = error.response.data.error.message;
} else if (error.response.data.message) {
errorMsg = error.response.data.message;
} else if (error.response.data.ui && error.response.data.ui.messages && error.response.data.ui.messages.length > 0) {
errorMsg = error.response.data.ui.messages.map(m => m.text).join(' ');
}
}
console.error('Login error:', error.response?.data || error);
res.redirect(`/?login_challenge=${challenge}&error=${encodeURIComponent(errorMsg)}`);
}
});
// Login endpoint for Hydra
app.get('/login', async (req, res) => {
const {login_challenge} = req.query;
if (!login_challenge) {
// If no challenge, redirect to main
return res.redirect('/');
}
// Redirect to main with challenge
res.redirect(`/?login_challenge=${login_challenge}`);
});
// Consent endpoint - automatically grant consent
app.get('/consent', async (req, res) => {
const {consent_challenge} = req.query;
if (!consent_challenge) {
return res.status(400).send('Missing consent_challenge');
}
try {
// Get consent request
const consentRequest = await axios.get(
`${HYDRA_ADMIN_URL}/admin/oauth2/auth/requests/consent?consent_challenge=${consent_challenge}`
);
// Use user traits from session if available
const userTraits = req.session.userTraits || {
email: 'test@example.com',
name: 'Test User',
roles: ['admin', 'user']
};
// Accept consent
const acceptResponse = await axios.put(
`${HYDRA_ADMIN_URL}/admin/oauth2/auth/requests/consent/accept?consent_challenge=${consent_challenge}`,
{
grant_scope: consentRequest.data.requested_scope,
grant_access_token_audience: consentRequest.data.requested_access_token_audience,
remember: true,
remember_for: 3600,
session: {
access_token: {
email: userTraits.email,
traits: userTraits
},
id_token: {
email: userTraits.email,
name: userTraits.name,
traits: userTraits
}
}
}
);
return res.redirect(acceptResponse.data.redirect_to);
} catch (error) {
console.error('Consent error:', error.response?.data || error);
res.status(500).send('Consent error');
}
});
// ============= Additional endpoints =============
// Logout
app.post('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
app.listen(port, () => {
console.log(`All-in-One OAuth2 App running at http://localhost:${port}`);
console.log('This app serves as:');
console.log(' - OAuth2 Client');
console.log(' - Login Provider');
console.log(' - Consent Provider');
});
Теперь создадим Dockerfile
для этой страницы:
FROM node:18-alpine
WORKDIR /app
RUN npm install express axios express-session crypto
COPY . .
EXPOSE 3001
CMD ["node", "index.js"]
Теперь добавим сервис в docker-compose.yml
:
hydra-token-page:
build:
context: ./docker/hydra
dockerfile: Dockerfile
ports:
- "3001:3001"
networks:
- intranet
Теперь у нас есть страница для получения токена доступа, которую мы можем использовать для тестирования. Запускаемdocker-compose up -d
, после чего можем перейти по адресу http://127.0.1:3001
и видим страницу для получения токена.
Теперь зарегистрируем клиента:
curl -X POST http://localhost:4445/admin/clients \
-H 'Content-Type: application/json' \
-d '{
"client_id": "web",
"client_secret": "**web-secret**",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code", "id_token"],
"scope": "openid offline users:read products:read orders:read",
"redirect_uris": ["http://127.0.0.1:4455/callback"]
}'
redirect_uris
указывает на Kratos‑UI (порт 4455), который мы уже подняли. offline
даёт refresh‑token, чтобы нелогиниться каждый час.

Теперь добавим роль пользователю, которого мы создали в Ory Kratos. Но просто добавить не можем, нам нужно модифицироватьсхему которую мы добавляли при создании Kratos. Лежит в папке docker/kratos/configs/identity.schema.json
, там мыдобавляем новое свойство roles
, сразу после names
:
{
"roles": {
"type": "array",
"items": {
"type": "string"
},
"description": "Список ролей пользователя, например: admin, user, moderator"
}
}
Перезагружаем контейнер не нужно, применяется сразу. Дальше идем получать ID identity, его можно получить на вкладеhttp://127.0.0.1:4455/sessions
. У меня это90a1e7d9-82ad-4f71-a1ab-f88ca81fd2e5
(будьте внимательны, id указывается в identity -> ID, а не первая строка). После чего отправляем запрос на добавление роли:
curl -X PATCH http://localhost:4434/admin/identities/90a1e7d9-82ad-4f71-a1ab-f88ca81fd2e5 \
-H 'Content-Type: application/json' \
-u '[
{
"op": "add",
"path": "/traits/roles/-",
"value": "admin"
}
]'
Теперь получаем токен доступа, что бы протестировать. Идем на нашу страницу http://127.0.0.1:3001
, вводим логин и пароль, после чего нажимаем "Login". После успешной аутентификации, вы увидите свой токен, а так же роли и scope, которые были указаны.

Проверяем что токен валидный:
curl -X POST http://127.0.0.1:4444/userinfo \
-d 'token=ВАШ_ТОКЕН'
Теперь идем настраивать Apache APISIX. Для этого нам нужно создать три наших маршрута.
Показываю для одного, вам достаточно будет изменить uri
и upstream
.
curl -X PUT http://localhost:9180/apisix/admin/routes/users \
-H "X-API-KEY: supersecret" \
-d '{
"uri": "/users/*",
"plugins": {
"openid-connect": {
"client_id": "web",
"client_secret": "web-secret",
"discovery": "http://hydra:4444/.well-known/openid-configuration",
"bearer_only": true,
"use_jwks": true,
"token_signing_alg_values_expected": "RS256"
}
},
"proxy-rewrite": {
"regex_uri": ["^/users/?(.*)", "/$1"]
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"users:8080": 1
}
}
}'
И если попытаем сделать запрос на ручку, то получим ответ:
2025/06/25 19:14:16 [warn] 63#63: *2352793 [lua] openid-connect.lua:435: introspect(): OIDC access discovery url failed : accessing discovery url (http://127.0.0.1:4444/.well-known/openid-configuration) failed: connection refused, client: 192.168.148.1, server: _, request: "GET /users/123 HTTP/1.1", host: "localhost:9080"
2025-06-25T19:14:16.273643707Z 2025/06/25 19:14:16 [error] 63#63: *2352793 [lua] openidc.lua:573: openidc_discover(): accessing discovery url (http://127.0.0.1:4444/.well-known/openid-configuration) failed: connection refused, client: 192.168.148.1, server: _, request: "GET /users/123 HTTP/1.1", host: "localhost:9080"
2025-06-25T19:14:16.273652832Z 2025/06/25 19:14:16 [error] 63#63: *2352793 [lua] openidc.lua:1006: openidc_load_jwt_and_verify_crypto(): accessing discovery url (http://127.0.0.1:4444/.well-known/openid-configuration) failed: connection refused, client: 192.168.148.1, server: _, request: "GET /users/123 HTTP/1.1", host: "localhost:9080"
Проблема в том, что APISIX пытается обратиться к Hydra по адресу http://127.0.1:4444
, а Hydra работает в контейнере и не доступна по этому адресу изнутри. Но если мы поменяем issuer на hydra, то мы не сможем получить токен, так как этот адрес не доступен из браузера. Самый простой способ - это добавить в файл /etc/hosts
на вашей машине строку:
127.0.0.1 hydra
После этого поменять в файле конфигурации Hydra docker/hydra/config/hydra.yml
значение issuer
на http://hydra:4444
. И в запросе на регистрацию клиента указать redirect_uris
как http://hydra:4444/callback
. Так же меняем env в нашем
hydra-token-page. Затем перейти в браузер и получить новый токен.
После этого APISIX сможет корректно прокинуть запросы.

Проверка ролей
У нас все настроено, но нам нужно проверить, что у нас есть соответствующая роль.
Так как изначально статья исследовательская, ожидал что APISIX сможет это сделать, ждем когда примут PR #11824.
С помощью него можно будет сделать что-то вроде:
{
"claim_validator": {
"roles": {
"claim": "ext.traits.roles",
"match": "any",
"value": [
"orders"
]
}
}
}
Потом я решил пойти в сторону добавления ролей в виде scope, например 'roles:order', но тут тоже оказалась проблема.
Hydra хранит scope в scp
в токене, а APISIX умеет брать данные только в scope, если мы добавляем проверку:
{
"required_scopes": [
"roles:order"
]
}
Поэтому если у вас система отдает JWT, то это рабочий вариант, но если вы используете Hydra, то тогда мы сразу отказываемся от JWT и переходим на opaque токены. Для этого нужно в конфиге Hydra изменить стратегию:
strategies:
access_token: opaque
После этого заходим на наш сайт, перевыпускаем токен, а дальше проверяем:
curl --request POST \
--url http://localhost:4445/oauth2/introspect \
--header "Content-Type: application/x-www-form-urlencoded" \
--user "web:web-secret" \
--data-urlencode "token=<ТВОЙ_ТОКЕН>"

Здесь уже по-человечески написано scope, а не scp. Теперь мы можем использовать APISIX для проверки ролей.
curl -X PUT http://localhost:9180/apisix/admin/routes/orders \
-H "X-API-KEY: supersecret" \
-d '{
"uri": "/users/*",
"plugins": {
"openid-connect": {
"client_id": "web",
"client_secret": "web-secret",
"discovery": "http://hydra:4444/.well-known/openid-configuration",
"bearer_only": true,
"introspection_endpoint": "http://hydra:4445/admin/oauth2/introspect",
"required_scopes": ["roles:order"]
},
"proxy-rewrite": {
"regex_uri": ["^/users/?(.*)", "/$1"]
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"users:8080": 1
}
}
}'

Теперь меняем на roles:users
и видим что ручка не доступна:

Проверка прав в микросервисах
Теперь мы можем проверять уже детальные права в рамках микросервиса. Для этого мы можем использовать middleware.
Микросервис пользователя у нас будет выглядеть следующим образом:
main.go
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/gorilla/mux"
)
type UserInfo struct {
Sub string `json:"sub"`
Scope string `json:"scope"`
ClientID string `json:"client_id"`
Active bool `json:"active"`
}
func AuthMiddleware(requiredScopes ...string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userInfoHeader := r.Header.Get("X-Userinfo")
if userInfoHeader == "" {
http.Error(w, "Unauthorized: Missing user info", http.StatusUnauthorized)
return
}
decoded, err := base64.StdEncoding.DecodeString(userInfoHeader)
if err != nil {
http.Error(w, "Unauthorized: Invalid user info format", http.StatusUnauthorized)
return
}
var userInfo UserInfo
if err := json.Unmarshal(decoded, &userInfo); err != nil {
http.Error(w, "Unauthorized: Invalid user info JSON", http.StatusUnauthorized)
return
}
if !userInfo.Active {
http.Error(w, "Unauthorized: Token is not active", http.StatusUnauthorized)
return
}
userScopes := strings.Split(userInfo.Scope, " ")
if !hasRequiredScopes(userScopes, requiredScopes) {
http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden)
return
}
r.Header.Set("X-User-ID", userInfo.Sub)
r.Header.Set("X-User-Scopes", userInfo.Scope)
r.Header.Set("X-Client-ID", userInfo.ClientID)
next.ServeHTTP(w, r)
})
}
}
func hasRequiredScopes(userScopes, requiredScopes []string) bool {
scopeMap := make(map[string]bool)
for _, scope := range userScopes {
scopeMap[scope] = true
}
for _, required := range requiredScopes {
if !scopeMap[required] {
return false
}
}
return true
}
func getUserInfo(r *http.Request) (string, []string) {
userID := r.Header.Get("X-User-ID")
scopes := strings.Split(r.Header.Get("X-User-Scopes"), " ")
return userID, scopes
}
func main() {
r := mux.NewRouter()
service := os.Getenv("SERVICE_NAME")
r.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s service is healthy!", service)
}).Methods("GET")
r.Handle("/", AuthMiddleware("users:read")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, scopes := getUserInfo(r)
fmt.Fprintf(w, "Hello from %s service! User: %s, Scopes: %v", service, userID, scopes)
}))).Methods("GET")
r.Handle("/", AuthMiddleware("users:write")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, _ := getUserInfo(r)
fmt.Fprintf(w, "Create in %s service by user %s!", service, userID)
}))).Methods("POST")
r.Handle("/{id}", AuthMiddleware("users:read")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID, _ := getUserInfo(r)
fmt.Fprintf(w, "Read %s with id %s by user %s!", service, vars["id"], userID)
}))).Methods("GET")
r.Handle("/{id}", AuthMiddleware("users:write")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID, _ := getUserInfo(r)
fmt.Fprintf(w, "Update %s with id %s by user %s!", service, vars["id"], userID)
}))).Methods("PUT")
r.Handle("/{id}", AuthMiddleware("users:delete")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
userID, _ := getUserInfo(r)
fmt.Fprintf(w, "Delete %s with id %s by user %s!", service, vars["id"], userID)
}))).Methods("DELETE")
r.Handle("/admin/stats", AuthMiddleware("admin:read", "users:read")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, scopes := getUserInfo(r)
fmt.Fprintf(w, "Admin stats for %s service. Admin: %s, Scopes: %v", service, userID, scopes)
}))).Methods("GET")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting %s service on port %s", service, port)
log.Fatal(http.ListenAndServe(":"+port, r))
}
Теперь если отправим POST запрос на /users
то получим ответ Forbidden: Insufficient permissions
:

Заключение
В рамках этой статьи мы разобрали два основных подхода к реализации авторизации и аутентификации в микросервисной архитектуре. Первый подход с JWT токенами подходит для простых систем с базовыми ролями, но имеет ограничения по размеру заголовков. Второй подход с opaque токенами и интроспекцией позволяет реализовать более гибкую систему разрешений, но добавляет сетевые накладные расходы.
Мы успешно интегрировали Ory Kratos для аутентификации и Ory Hydra для авторизации, настроили Apache APISIX в качестве API Gateway и создали middleware для проверки детальных разрешений в микросервисах. Такая архитектура даёт нам:
Централизованное управление доступом через API Gateway
Гибкую систему разрешений на уровне отдельных операций
Масштабируемость за счёт микросервисной архитектуры Ory
Безопасность через использование стандартных протоколов OAuth2/OIDC
В продакшене не забудьте:
Использовать HTTPS для всех соединений
Настроить надежные пароли и секреты
Добавить мониторинг и логирование
Настроить кэширование результатов интроспекции
Продумать стратегию ротации токенов
Весь код из статьи доступен в репозитории на GitHub, где вы можете поэкспериментировать с разными настройками и адаптировать решение под свои нужды.
P.S. Пока готовил статью, так как она достаточно исследовательская, понял что проще всего вообще микросервисы держать открытыми, а авторизацию делать только на уровне API Gateway.
Сильно чаще чем статьи я пишу в своем канале в ТГ, подписывайтесь.