ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • ImpRun 인증/인가 아키텍처: Ory 스택 통합 구현 가이드
    실제 경험과 인사이트를 AI와 함께 정리한 글 2025. 12. 7. 10:58

    작성일: 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

    참고 자료

    공식 문서

    관련 문서

Designed by Tistory.