티스토리 뷰

작성일: 2025년 12월 6일
카테고리: Authentication, Authorization, Architecture, Backend
키워드: Ory Kratos, Ory Hydra, Ory Keto, Ory Oathkeeper, Zero Trust, OAuth2, ReBAC

요약

ImpRun v3 플랫폼은 Ory 스택 전체(Kratos, Hydra, Keto, Oathkeeper)를 통합하여 엔터프라이즈급 인증/인가 시스템을 구축했다. 각 컴포넌트가 명확한 역할을 담당하며, Zero Trust 아키텍처를 기반으로 웹 애플리케이션과 외부 API 클라이언트를 모두 지원한다. 이 글에서는 4개 Ory 컴포넌트의 조합 방식과 실제 구현 사례를 다룬다.

Ory 스택 구성

컴포넌트별 역할

컴포넌트 역할 포트
Ory Kratos Identity Management - 사용자 등록, 로그인, 세션 관리 4433 (Public), 4434 (Admin)
Ory Hydra OAuth2/OIDC Server - 토큰 발급, 클라이언트 관리 4444 (Public), 4445 (Admin)
Ory Keto Permissions - ReBAC 기반 권한 관리 4466 (Read), 4467 (Write)
Ory Oathkeeper Identity & Access Proxy - 인증/인가 게이트키퍼 4455 (Proxy), 4456 (API)

전체 아키텍처

flowchart TB
    subgraph clients["Clients"]
        Browser["Browser<br/>(Next.js)"]
        External["External API<br/>Client"]
        M2M["M2M Service"]
    end

    subgraph gateway["Gateway Layer"]
        Envoy["Envoy Gateway"]
        Oathkeeper["Oathkeeper<br/>(IAP)"]
    end

    subgraph auth["Ory Auth Stack"]
        Kratos["Kratos<br/>(Identity)"]
        Hydra["Hydra<br/>(OAuth2)"]
        Keto["Keto<br/>(Permissions)"]
    end

    subgraph backend["Backend"]
        GoAPI["Go API Server"]
        DB["PostgreSQL"]
    end

    Browser -->|"Session Cookie"| Envoy
    External -->|"Bearer Token"| Envoy
    M2M -->|"Client Credentials"| Hydra

    Envoy <-->|"ext_authz"| Oathkeeper
    Oathkeeper -->|"whoami"| Kratos
    Oathkeeper -->|"introspect"| Hydra

    Envoy -->|"X-User-* headers"| GoAPI
    GoAPI -->|"Permission Check"| Keto
    GoAPI --> DB

    Kratos --> DB
    Hydra --> DB
    Keto --> DB

    style gateway stroke:#dc2626,stroke-width:2px
    style auth stroke:#16a34a,stroke-width:2px
    style backend stroke:#2563eb,stroke-width:2px

인증 방식별 플로우

클라이언트 유형별 인증

클라이언트 인증 방식 토큰 타입 Oathkeeper Handler
Web App (Next.js) Kratos Session Cookie cookie_session
External API Hydra OAuth2 Bearer Token bearer_token
M2M Client Credentials Bearer Token bearer_token

1. 웹 로그인 플로우 (Kratos Session)

sequenceDiagram
    participant Browser
    participant NextJS as Next.js
    participant Kratos
    participant Envoy
    participant Oathkeeper
    participant GoAPI as Go API

    Note over Browser,GoAPI: 로그인 Flow
    Browser->>NextJS: 1. /login 접속
    NextJS->>Kratos: 2. init flow
    Kratos-->>NextJS: flow data
    NextJS-->>Browser: render form

    Browser->>NextJS: 3. submit credentials
    NextJS->>Kratos: submit to Kratos
    Kratos-->>Browser: 4. 세션 쿠키 발급<br/>(ory_kratos_session)

    Note over Browser,GoAPI: API 요청 Flow
    Browser->>Envoy: 5. API 요청 (with cookie)
    Envoy->>Oathkeeper: 6. ext_authz
    Oathkeeper->>Kratos: 7. /sessions/whoami
    Kratos-->>Oathkeeper: session info
    Oathkeeper-->>Envoy: 8. OK + X-User-* headers
    Envoy->>GoAPI: 9. Forward request
    GoAPI-->>Browser: response

2. 외부 API 플로우 (OAuth2 Bearer Token)

sequenceDiagram
    participant Client as API Client
    participant Hydra
    participant Envoy
    participant Oathkeeper
    participant GoAPI as Go API

    Note over Client,GoAPI: 토큰 발급
    Client->>Hydra: 1. POST /oauth2/token<br/>(client_credentials)
    Hydra-->>Client: access_token

    Note over Client,GoAPI: API 요청
    Client->>Envoy: 2. API 요청<br/>(Authorization: Bearer ...)
    Envoy->>Oathkeeper: 3. ext_authz
    Oathkeeper->>Hydra: 4. POST /oauth2/introspect
    Hydra-->>Oathkeeper: token info (active, scope, sub)
    Oathkeeper-->>Envoy: 5. OK + headers
    Envoy->>GoAPI: 6. Forward request
    GoAPI-->>Client: response

3. 권한 검사 플로우 (Keto ReBAC)

sequenceDiagram
    participant GoAPI as Go API
    participant Keto
    participant DB

    Note over GoAPI,DB: Gateway 접근 권한 검사
    GoAPI->>Keto: 1. Check permission<br/>(Gateway:gw-id#view@User:user-id)

    Note over Keto: Relation Tuple 검색<br/>+ SubjectSet 확장
    Keto->>DB: 2. Query relation tuples
    DB-->>Keto: tuples

    alt 직접 권한
        Keto-->>GoAPI: 3a. allowed: true
    else 조직 상속 권한
        Note over Keto: Organization:org-id#members<br/>→ Gateway:gw-id#viewers
        Keto-->>GoAPI: 3b. allowed: true
    else 권한 없음
        Keto-->>GoAPI: 3c. allowed: false
    end

Ory 컴포넌트 상세 설정

Kratos 설정 (Identity Management)

# kratos.yml
version: v1.1.0

dsn: postgres://kratos:secret@postgres:5432/kratos

serve:
  public:
    base_url: http://localhost:3000/.ory/
    cors:
      enabled: true
      allowed_origins:
        - http://localhost:3000

selfservice:
  default_browser_return_url: http://localhost:3000/

  methods:
    password:
      enabled: true
    oidc:
      enabled: true
      config:
        providers:
          - id: google
            provider: google
            client_id: ${GOOGLE_CLIENT_ID}
            client_secret: ${GOOGLE_CLIENT_SECRET}
            scope: [email, profile]

  flows:
    login:
      ui_url: http://localhost:3000/auth/login
    registration:
      ui_url: http://localhost:3000/auth/registration
      after:
        password:
          hooks:
            - hook: session  # 회원가입 후 자동 로그인

session:
  lifespan: 24h
  cookie:
    name: ory_kratos_session
    same_site: Lax

identity:
  default_schema_id: user
  schemas:
    - id: user
      url: file:///etc/config/kratos/schemas/user.schema.json

Hydra 설정 (OAuth2/OIDC)

# hydra.yml
version: v2.2.0

dsn: postgres://hydra:secret@postgres:5432/hydra

serve:
  public:
    cors:
      enabled: true
      allowed_origins:
        - http://localhost:3000

urls:
  self:
    issuer: http://localhost:4444/
  consent: http://localhost:3000/auth/consent
  login: http://localhost:3000/auth/login
  logout: http://localhost:3000/auth/logout

strategies:
  access_token: jwt

oauth2:
  allowed_top_level_claims:
    - namespace
    - roles
    - scopes

Keto 설정 (Permissions)

# keto.yml
version: v0.14.0

dsn: postgres://keto:secret@postgres:5432/keto

namespaces:
  location: file:///etc/config/keto/namespaces.keto.ts

serve:
  read:
    host: 0.0.0.0
    port: 4466
  write:
    host: 0.0.0.0
    port: 4467

OPL Namespace 정의:

// namespaces.keto.ts
class User implements Namespace {}

class Organization implements Namespace {
  related: {
    owners: User[]
    admins: User[]
    members: User[]
  }

  permits = {
    manage: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject) ||
      this.related.admins.includes(ctx.subject),

    view: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject) ||
      this.related.admins.includes(ctx.subject) ||
      this.related.members.includes(ctx.subject),
  }
}

class Gateway implements Namespace {
  related: {
    owners: (User | SubjectSet<Organization, "admins">)[]
    admins: (User | SubjectSet<Organization, "admins">)[]
    members: User[]
    viewers: (User | SubjectSet<Organization, "members">)[]
    organization: Organization[]
  }

  permits = {
    manage: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject) ||
      this.related.admins.includes(ctx.subject),

    view: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject) ||
      this.related.admins.includes(ctx.subject) ||
      this.related.members.includes(ctx.subject) ||
      this.related.viewers.includes(ctx.subject),

    delete: (ctx: Context): boolean =>
      this.related.owners.includes(ctx.subject),
  }
}

Oathkeeper 설정 (IAP)

# oathkeeper.yml
serve:
  proxy:
    port: 4455
  api:
    port: 4456

access_rules:
  repositories:
    - file:///etc/config/oathkeeper/access-rules.yml

authenticators:
  noop:
    enabled: true
  cookie_session:
    enabled: true
    config:
      check_session_url: http://kratos:4433/sessions/whoami
      preserve_path: true
      extra_from: "@this"
      subject_from: "identity.id"
      only:
        - ory_kratos_session
  bearer_token:
    enabled: true
    config:
      check_session_url: http://hydra:4445/oauth2/introspect
      token_from:
        header: Authorization

authorizers:
  allow:
    enabled: true

mutators:
  header:
    enabled: true
    config:
      headers:
        X-User-Id: "{{ print .Subject }}"
        X-User-Email: "{{ print .Extra.identity.traits.email }}"

errors:
  fallback:
    - json
  handlers:
    json:
      enabled: true
    redirect:
      enabled: true
      config:
        to: http://localhost:3000/auth/login

Go 백엔드 통합

클라이언트 구조

// internal/infrastructure/client/ory_clients.go
package client

type OryClients struct {
    Kratos *KratosClient
    Hydra  *HydraClient
    Keto   *KetoClient
}

func NewOryClients(cfg *config.OryConfig) *OryClients {
    return &OryClients{
        Kratos: NewKratosClient(cfg.Kratos),
        Hydra:  NewHydraClient(cfg.Hydra),
        Keto:   NewKetoClient(cfg.Keto),
    }
}

통합 인증 미들웨어

Oathkeeper가 전달한 헤더를 사용하거나, 직접 Kratos/Hydra를 호출하는 미들웨어:

// internal/interface/middleware/auth.go
func AuthMiddleware(oryClients *client.OryClients, useOathkeeper bool) gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. Oathkeeper 모드: 헤더에서 사용자 정보 추출
        if useOathkeeper {
            userID := c.GetHeader("X-User-Id")
            if userID != "" {
                user := &entity.UserSession{
                    ID:        uuid.MustParse(userID),
                    Email:     c.GetHeader("X-User-Email"),
                    TokenType: "oathkeeper",
                }
                c.Set("user", user)
                c.Next()
                return
            }
        }

        // 2. Bearer Token 검증 (Hydra)
        authHeader := c.GetHeader("Authorization")
        if strings.HasPrefix(authHeader, "Bearer ") {
            token := strings.TrimPrefix(authHeader, "Bearer ")
            introspection, err := oryClients.Hydra.Introspect(c.Request.Context(), token)
            if err == nil && introspection.Active {
                user := &entity.UserSession{
                    ID:        uuid.MustParse(introspection.Subject),
                    TokenType: "oauth2",
                    Scopes:    introspection.Scope,
                }
                c.Set("user", user)
                c.Next()
                return
            }
        }

        // 3. Session Cookie 검증 (Kratos)
        sessionCookie, err := c.Cookie("ory_kratos_session")
        if err == nil && sessionCookie != "" {
            session, err := oryClients.Kratos.WhoAmI(c.Request.Context(), sessionCookie)
            if err == nil && session.Active {
                user := &entity.UserSession{
                    ID:        uuid.MustParse(session.Identity.ID),
                    Email:     session.Identity.Traits.Email,
                    TokenType: "session",
                }
                c.Set("user", user)
                c.Next()
                return
            }
        }

        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
            "error": "No valid authentication provided",
        })
    }
}

Keto 권한 검사 미들웨어

// internal/interface/middleware/permission.go
func GatewayPermissionMiddleware(ketoClient *client.KetoClient, permission string) gin.HandlerFunc {
    return func(c *gin.Context) {
        gatewayID := c.Param("gw")
        if gatewayID == "" {
            c.Next()
            return
        }

        user, exists := c.Get("user")
        if !exists {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "Unauthorized",
            })
            return
        }

        userSession := user.(*entity.UserSession)

        // Keto 권한 검사
        allowed, err := ketoClient.Check(c.Request.Context(), KetoCheckRequest{
            Namespace: "Gateway",
            Object:    gatewayID,
            Relation:  permission,
            SubjectID: userSession.ID.String(),
        })

        if err != nil {
            c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
                "error": "Permission check failed",
            })
            return
        }

        if !allowed {
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
                "error":    "Forbidden",
                "resource": "Gateway",
                "action":   permission,
            })
            return
        }

        c.Set("currentGateway", gatewayID)
        c.Next()
    }
}

라우터 설정

// internal/interface/router/router.go
func SetupRouter(oryClients *client.OryClients) *gin.Engine {
    r := gin.Default()

    // Public endpoints
    public := r.Group("/api/public")
    {
        public.GET("/health", healthCheck)
        public.GET("/directory/*path", getDirectory)
    }

    // Protected endpoints
    protected := r.Group("/api")
    protected.Use(AuthMiddleware(oryClients, true))
    {
        // User endpoints
        protected.GET("/me", getMe)
        protected.GET("/organizations", listOrganizations)

        // Gateway endpoints with permission checks
        gw := protected.Group("/gateways/:gw")
        {
            gw.GET("", GatewayPermissionMiddleware(oryClients.Keto, "view"), getGateway)
            gw.PUT("", GatewayPermissionMiddleware(oryClients.Keto, "manage"), updateGateway)
            gw.DELETE("", GatewayPermissionMiddleware(oryClients.Keto, "delete"), deleteGateway)

            // Products
            gw.GET("/products", GatewayPermissionMiddleware(oryClients.Keto, "view"), listProducts)
            gw.POST("/products", GatewayPermissionMiddleware(oryClients.Keto, "manage"), createProduct)

            // Config
            gw.PUT("/config", GatewayPermissionMiddleware(oryClients.Keto, "publish_config"), publishConfig)
        }
    }

    return r
}

Next.js 프론트엔드 통합

Ory Client 설정

// shared/api/ory.ts
import { Configuration, FrontendApi } from '@ory/client';

const KRATOS_URL = '/.ory';

export const kratosClient = new FrontendApi(
  new Configuration({
    basePath: KRATOS_URL,
    baseOptions: {
      withCredentials: true,
    },
  })
);

Next.js Proxy 설정

// next.config.ts
const nextConfig: NextConfig = {
  async rewrites() {
    return [
      {
        source: '/.ory/:path*',
        destination: `${process.env.KRATOS_URL || 'http://localhost:4433'}/:path*`,
      },
    ];
  },
};

Auth Provider

// entities/auth/model/auth-provider.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { Session } from '@ory/client';
import { kratosClient } from '@/shared/api/ory';

interface AuthContextType {
  session: Session | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    kratosClient.toSession()
      .then(({ data }) => {
        setSession(data);
      })
      .catch(() => {
        setSession(null);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  const logout = async () => {
    const { data } = await kratosClient.createBrowserLogoutFlow();
    window.location.href = data.logout_url;
  };

  return (
    <AuthContext.Provider value={{
      session,
      isAuthenticated: !!session,
      isLoading,
      logout,
    }}>
      {children}
    </AuthContext.Provider>
  );
}

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
};

Docker Compose 전체 구성

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: root
      POSTGRES_PASSWORD: secret
    volumes:
      - ./docker/postgres/init:/docker-entrypoint-initdb.d
      - postgres_data:/var/lib/postgresql/data

  kratos:
    image: oryd/kratos:v1.1.0
    environment:
      DSN: postgres://kratos:secret@postgres:5432/kratos
    ports:
      - "4433:4433"
      - "4434:4434"
    volumes:
      - ./docker/ory/kratos:/etc/config/kratos
    command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
    depends_on:
      - postgres

  hydra:
    image: oryd/hydra:v2.2.0
    environment:
      DSN: postgres://hydra:secret@postgres:5432/hydra
      URLS_CONSENT: http://localhost:3000/auth/consent
      URLS_LOGIN: http://localhost:3000/auth/login
    ports:
      - "4444:4444"
      - "4445:4445"
    volumes:
      - ./docker/ory/hydra:/etc/config/hydra
    command: serve all -c /etc/config/hydra/hydra.yml --dev
    depends_on:
      - postgres

  keto:
    image: oryd/keto:v0.14.0
    environment:
      DSN: postgres://keto:secret@postgres:5432/keto
    ports:
      - "4466:4466"
      - "4467:4467"
    volumes:
      - ./docker/ory/keto:/etc/config/keto
    command: serve -c /etc/config/keto/keto.yml
    depends_on:
      - postgres

  oathkeeper:
    image: oryd/oathkeeper:v0.40.7
    ports:
      - "4455:4455"
      - "4456:4456"
    volumes:
      - ./docker/ory/oathkeeper:/etc/config/oathkeeper
    command: serve --config /etc/config/oathkeeper/oathkeeper.yml
    depends_on:
      - kratos
      - hydra

  envoy:
    image: envoyproxy/envoy:v1.32-latest
    ports:
      - "8080:8080"
      - "9901:9901"
    volumes:
      - ./docker/envoy/envoy.yaml:/etc/envoy/envoy.yaml
    depends_on:
      - oathkeeper

  api:
    build:
      context: ./apps/api
      dockerfile: Dockerfile
    environment:
      - KRATOS_PUBLIC_URL=http://kratos:4433
      - KRATOS_ADMIN_URL=http://kratos:4434
      - HYDRA_PUBLIC_URL=http://hydra:4444
      - HYDRA_ADMIN_URL=http://hydra:4445
      - KETO_READ_URL=http://keto:4466
      - KETO_WRITE_URL=http://keto:4467
      - USE_OATHKEEPER=true
    depends_on:
      - kratos
      - hydra
      - keto

  web:
    build:
      context: ./apps/web
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - KRATOS_URL=http://kratos:4433
    depends_on:
      - api
      - kratos

  mailhog:
    image: mailhog/mailhog
    ports:
      - "8025:8025"

volumes:
  postgres_data:

데이터 흐름 요약

인증 데이터 흐름

1. 사용자 로그인 (Kratos)
   Browser → Next.js → Kratos → Session Cookie

2. API 요청
   Browser (Cookie) → Envoy → Oathkeeper → Kratos (whoami)
                                         → Headers 추가
                                         → Go API

3. 권한 검사
   Go API → Keto (check) → allowed/denied

4. 사용자 데이터 동기화
   Go API (첫 요청) → Kratos Identity → User 테이블 생성

컴포넌트별 책임

컴포넌트 핵심 질문 담당 기능
Kratos WHO - 사용자가 누구인가? 회원가입, 로그인, 세션 관리, 소셜 로그인 (OIDC), 이메일 인증, 비밀번호 복구
Hydra TOKEN - 접근 토큰은 무엇인가? Access Token / Refresh Token 발급, Client Credentials (M2M), Token Introspection
Keto CAN - 무엇을 할 수 있는가? ReBAC 기반 권한 모델, Relation Tuple 관리, 권한 상속 (Organization → Gateway)
Oathkeeper GATE - 통과할 수 있는가? 모든 요청 검증, Kratos/Hydra 연동, 사용자 정보 헤더 주입

운영 고려사항

성능

  • 각 Ory 컴포넌트는 stateless → 수평 확장 가능
  • PostgreSQL 연결 풀 적절히 설정
  • Oathkeeper 세션 캐싱으로 Kratos/Hydra 부하 감소

보안

  1. Admin API 격리: 4434, 4445, 4467 포트는 내부 네트워크에서만 접근
  2. HTTPS 필수: 프로덕션에서 모든 통신 암호화
  3. Secret 관리: 환경 변수 또는 Vault 사용

모니터링

각 컴포넌트는 Prometheus 메트릭 제공:

# 메트릭 엔드포인트
Kratos:     http://kratos:4434/metrics
Hydra:      http://hydra:4445/metrics
Keto:       http://keto:4468/metrics
Oathkeeper: http://oathkeeper:9000/metrics

참고 자료

공식 문서

관련 문서

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
글 보관함