Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение25 мин
Количество просмотров1.2K

Мне кажется, что уже есть сотни разных статей на эту тему, но каждый раз мне чего-то не хватало. Поэтому я решил написать свою статью, в которой покажу, как я реализую авторизацию и аутентификацию в своих проектах. Это именно гайд: вы можете взять готовый код и адаптировать его под свои нужды. В рамках статьи будут использоваться 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.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.

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

Теги:
Хабы:
+1
Комментарии2

Публикации

Ближайшие события