Практический гайд по авторизации и аутентификации в микросервисах с Ory и Apache APISIX

от автора

Мне кажется, что уже есть сотни разных статей на эту тему, но каждый раз мне чего-то не хватало. Поэтому я решил написать свою статью, в которой покажу, как я реализую авторизацию и аутентификацию в своих проектах. Это именно гайд: вы можете взять готовый код и адаптировать его под свои нужды. В рамках статьи будут использоваться Ory Hydra и Ory Kratos, Apache APISIX в качестве API Gateway и несколько микросервисов на Golang. Всё это будет работать в Docker, чтобы вы могли легко запустить и поиграться.

На теорию много времени тратить не буду, можете посмотреть несколько статей по ссылкам ниже:

Сначала я хотел показать всё в рамках Kubernetes, но потом решил, что это будет излишне сложно в рамках статьи, поэтому
покажу на примере простого docker-compose, чтобы каждый мог легко запустить.

В рамках статьи я иду на определённые упрощения, например, использую стандартные пароли, не использую https и т.д. Это сделано для того, чтобы вы могли легко запустить и протестировать систему. В реальных проектах, конечно же, так делать не нужно.

Архитектура

Если с аутентификацией всё более-менее понятно, то с авторизацией всё сложнее. Можно выделить несколько основных сценариев работы:

  1. Используем простые, статичные роли, которые редко меняются. Например, admin, user, guest и т.д.

  2. Нужен больший контроль и динамическое управление доступом, например, разрешения на определенные действия для разных ролей. И делать все атомарно. Тогда у нас появляется дополнительный уровень — разрешения/права, которые назначаются в рамках микросервиса, а потом назначаются на конкретные роли. Здесь уже явно нужен отдельный сервис, как минимум для централизованного управления, но всё ещё не требуется специализированная система.

  3. Нужна сложная система управления доступом, которая может включать в себя 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. Он будет отвечать за проверку токена и ролей.

В первом случае у нас будет следующий путь:

  1. Пользователь проходит аутентификацию через отдельный сервис.

  2. Сервис аутентификации выдаёт JWT токен, который содержит информацию о пользователе и его ролях.

  3. Пользователь отправляет запросы к микросервисам, передавая JWT токен в заголовке Authorization.

  4. Запрос проходит через API Gateway, который проверяет JWT токен и извлекает информацию о пользователе.

  5. API Gateway проверяет, есть ли у пользователя нужная роль, с которой можно в микросервис, и перенаправляет запрос к нужному микросервису.

  6. Микросервис выполняет действие и возвращает ответ. В рамках самого микросервиса проверки уже не делаем.

Во втором случае у нас будет похожий путь, но вместо JWT мы выдаём opaque токен, в API Gateway также проверяем роли после запроса к сервису аутентификации, а на микросервисах добавляем middleware, который будет проверять разрешения на ручках.

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

Система авторизации/аутентификации

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

Я думаю, что многие хоть раз слышали о Keycloak. Он надёжен, стабилен и имеет много
возможностей, но у него есть пара недостатков:

  1. Это довольно большой комбайн, который может быть избыточным.

  2. У него монолитная архитектура, что может усложнить масштабирование и поддержку. Но самое главное в этом — если вы не знаете Java, то вам будет сложно его настраивать и поддерживать. Даже банально сделать свою страницу входа — уже не просто.

Ок, мы понимаем, что нам нужно что-то простое и гибкое. В идеале — что-то, что можно легко развернуть и настроить, API-first. Стек должен быть доступным, чтобы иметь возможность, например, написать своего провайдера. Также одним из требований — развертывание в нашей инфраструктуре, а не в облаке. И тут остаётся много вариантов, но я вначале остановился на нескольких:

  1. Ory Hydra / Ory Kratos — микросервисная архитектура с четким разделением ответственности. Написан на Go, хорошо документирован. Зрелый проект с консервативным подходом к релизам (2 релиза в год). Используется в OpenAPI Initiative, что говорит о надежности. 12к звезд на GitHub. Kratos управляет идентичностями и аутентификацией, Hydra — OAuth2/OIDC провайдер.

  2. Casdoor — простой старт «из коробки» с готовыми React-компонентами. Поддерживает все популярные методы аутентификации, включая социальные сети. Написан на Go, легко расширяется кастомными провайдерами. 11к звезд на GitHub. Из минусов — частые релизы усложняют поддержку, медленное исправление уязвимостей, переусложненный UI для управления ролями. Команда сейчас фокусируется на SaaS-решении, что может повлиять на open-source версию.

  3. 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.1hydra

После этого поменять в файле конфигурации 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.

Сильно чаще чем статьи я пишу в своем канале в ТГ, подписывайтесь.


ссылка на оригинал статьи https://habr.com/ru/articles/921896/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *