Привет, Хаброжители! Продолжаем делиться с вами экспертизой отдела Security services infrastructure (департамент Security Services компании «Лаборатории Касперского»).
Предыдущую статью нашей команды вы можете прочесть вот здесь: Keycloak. Админский фактор и запрет аутентификации

В этой части продолжим настраивать IAM с упором на отказоустойчивость и безопасность. Статья рассчитана на людей, которые ранее были знакомы с IAM и, в частности, с keycloak-ом. Поэтому в этой части не будет «базы» по SAML2, OAuth2/OIDC и в целом по IAM (на Хабре есть хорошие статьи на эту тему). Также для понимания данной статьи необходимы знания базовых абстракций kubernetes и умение читать его манифесты.



Рассмотрим два кейса:
  1. Как в свежей версии keycloak (v.22.0.3) настроить отказоустойчивость при развертывании в k8s в режиме standalone-ha.
  2. Как закрыть ненужные векторы атаки, ограничив пользователям доступ только до нужных путей, но оставив возможность админам заходить на консоль админки keycloak.


Первый кейс. Keycloak standalone-HA в k8s (v.22.0.3)


На данную тему уже были хорошие статьи на Хабре, вот примеры:
  1. Запускаем Keycloak в HA-режиме на Kubernetes (2020 год, Southbridge).
  2. Настраиваем отказоустойчивый Keycloak с Infinispan в Kubernetes (2021 год, Флант).

Плагиатить данные статьи не имею желания и не вижу смысла. Теорию по HA для keycloak вы можете взять из них.

В своей статье постараюсь описать, что же изменилось в настройке на период Q3–Q4 2023 года и как сейчас можно без «головной боли» настроить standalone-ha в k8s.

Изменения:
  1. Keycloak перешел с выделенного сервера приложений WildFly на Quarkus (на момент написания статьи версия 3.2.5.Final).
  2. Cменилось registry c jboss/keycloak на quay.io/keycloak/keycloak.
  3. Старые версии образов используют устаревшие переменные среды, которые в современных версиях не поддерживаются (работу с тегом -legacy не проверял).
  4. И самое главное: в статьях прошлых лет нет манифестов для развертывания keycloak в режиме standalone-ha в k8s, хотя многим компаниям/проектам этого режима достаточно для базовой отказоустойчивости инструмента аутентификации/авторизации (а при необходимости и идентификации) для производственной среды.

Напомню, что keycloak может быть развернут в следующих режимах: standalone, standalone-ha, domain cluster, DC replication.

Режим standalone-ha, у keycloak развернутого в k8s, включает в себя следующие элементы или наборы подов в нашем случае:
  1. Поды с выделенным сервером приложений Quarkus и встроенным модулем Infinispam, собранным в кластер (Key-value database, используется для хранения кэша, аналог redis-a).
  2. Поды распределенной СУБД, собранной в кластер, либо отдельно стоящий кластер СУБД.
  3. Reverse-proxy (в нашем случае ingress-controller), чтобы балансировать нагрузку.

Как собрать кластер СУБД внутри k8s, в данной статье описывать не будем, для базового примера с Postgresql можете развернуть Хельм-чарт от Bitnami: PostgreSQL или PostgreSQL-ha. Либо посмотреть в сторону k8s-операторов СУБД (у Фланта есть хорошие статьи на эту тему на Хабре). Как развертывать ingress-controller в k8s, в данной статье опустим (это популярный кейс и легко гуглится).

Берем за исходные данные то, что у нас перед развертыванием keycloak задеплоены PosgreSQL и ingress-controller (Nginx).

Что касаемо самого keycloak, для удобства развертывания вы можете использовать чарт от Bitnami: keycloak, но для понимания мы развернем standalone-ha keycloak, используя манифесты куба, + Bitnami любят менять название переменных в своих образах, которые отличаются от официальной документации keycloak-a, а это может внести путаницу.

Необходимые нам манифесты:

1) Service. Нужен для балансировки внешних запросов (запросов аутентификации) от пользователей. То есть чтобы пользователя забрасывало на разные вебки keycloak при аутентификации.

---
apiVersion: v1
kind: Service
metadata:
  name: keycloak-http
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 8080
      protocol: TCP
  selector:
    app: keycloak-ha

2) Headless Service. Нужен для определения количества подов кластера Infinispan. Так как headless-service не имеет собственного ip-адреса, то при использовании протокола обнаружения узлов кластера JGroups, такого как DNS_PING, он в ответе получит ip-адреса всех эндпоинтов keycloak+infinispan. Протоколы обнаружения JDBC_PING и KUBE_PING в режиме standalone-ha не используются.

---
apiVersion: v1
kind: Service
metadata:
  name: keycloak-headless
spec:
  type: ClusterIP
  clusterIP: None
  selector:
    app: keycloak-ha

3) StatefulSet. Нужен для развертывания реплик сервера приложений Quarkus со встроенным модулем Infinispam. Используется StatefulSet, а не Deployment, так как StatefulSet может использовать Headless Service для управления доменом своих подов. Поле serviceName в StatefulSet как раз для этого и необходимо.

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: keycloak
  labels:
    app: keycloak-ha
spec:
  selector:
    matchLabels:
      app: keycloak-ha
  replicas: 2
  serviceName: keycloak-headless
  podManagementPolicy: Parallel
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: keycloak-ha
    spec:
      restartPolicy: Always
      securityContext:
        fsGroup: 1000
#      priorityClassName: high
      containers:
        - name: keycloak
          image: quay.io/keycloak/keycloak:22.0.3
          imagePullPolicy: Always
          resources:
            limits:
              memory: 1500Mi
            requests:
              memory: 500Mi
              cpu: 100m
          securityContext:
            runAsNonRoot: true
            runAsUser: 1000
            capabilities:
              drop:
                - ALL
                - CAP_NET_RAW
            readOnlyRootFilesystem: false # Quarkus не запускается если данное поле securityContext-a выставить в "true"
            allowPrivilegeEscalation: false
          args:
            - start
          env:
            - name: KC_METRICS_ENABLED
              value: "true"
            - name: KC_LOG_LEVEL
              value: "info"          
            - name: KC_CACHE # тут мы указываем что кеш будем хранить в infinispan
              value: "ispn"
            - name: KC_CACHE_STACK # тут мы указываем, какую конфигурацию нужно выбрать infinispan-у что б он работал в кубе с протоколом обнаружения DNS_PING
              value: "kubernetes"
            - name: KC_PROXY
              value: "edge"
            - name: KEYCLOAK_ADMIN
              valueFrom:
                secretKeyRef:
                  name: ...
                  key: "..."
            - name: KEYCLOAK_ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ...
                  key: "..." 
            - name: KC_DB
              value: "postgres"
            - name: KC_DB_URL_HOST
              value: "postgresql-keycloak"
            - name: KC_DB_URL_PORT
              value: "5432"
            - name: KC_DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: ...
                  key: "..." 
            - name: KC_DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ...
                  key: "..." 
            - name: KC_DB_URL_DATABASE
              valueFrom:
                secretKeyRef:
                  name: ...
                  key: "..." 
            - name: KC_FEATURES
              value: "docker"
            - name: KC_HOSTNAME
              value: "keycloak.example.ru"
            - name: JAVA_OPTS_APPEND # обязательное поле необходимое для работы DNS_PING, указываем наш Headless Service
              value: "-Djgroups.dns.query=keycloak-headless.keycloak.svc.cluster.local"
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /
              port: http
            initialDelaySeconds: 120
            timeoutSeconds: 5
          readinessProbe:
            httpGet:
              path: /realms/master
              port: http
            initialDelaySeconds: 60
            timeoutSeconds: 1
      terminationGracePeriodSeconds: 60

4) Ingress. Нужен для доступа веба извне, для пользователей, которые будут проходить аутентификацию через keycloak.

Также на нем выставляем привязку сессий (Sticky Sessions), чтобы все запросы пользователя в рамках одной сессии передавались на один под. Иначе мы усложним жизнь infinispan-y, которому придется передавать данные о сессиях пользователей между подами.

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
  annotations:
    # следующие 4 строки аннотаций настраивают Sticky Sessions на ingress-e
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-expires: "86400"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
    nginx.ingress.kubernetes.io/session-cookie-name: "keycloak-cookie"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
      - keycloak.examlple.ru
      secretName: web-tls
  rules:
  - host: keycloak.examlple.ru
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: keycloak-http
            port:
              name: http
      #путь "/" слишком избыточен для пользователей и создает дополнительные векторы атаки,
      #тут он представлен только для примера, во 2-м кейсе этой статьи пофиксим =)

Деплоим это все в k8s и смотрим, собрался ли наш кластер infinispan:

kubectl apply -f <folder>

Если в логах контейнеров такого вида строки (отображается два элемента keycloak: keycloak-1-XXXXX, keycloak-0-XXXXX), то значит, кластер infinispam собрался

2023-09-08 13:48:20,514 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000078: Starting JGroups channel `ISPN` with stack `kubernetes`
2023-09-08 13:48:20,518 INFO  [org.jgroups.JChannel] (keycloak-cache-init) local_addr: b4d6190d-e9cb-4a3c-8d01-c611129f2a3b, name: keycloak-0-11230
2023-09-08 13:48:20,530 INFO  [org.jgroups.protocols.FD_SOCK2] (keycloak-cache-init) server listening on *.57800
2023-09-08 13:48:20,672 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-40291|15] (2) [keycloak-1-40291, keycloak-0-11230]
2023-09-08 13:48:20,783 INFO  [org.infinispan.CLUSTER] (keycloak-cache-init) ISPN000079: Channel `ISPN` local address is `keycloak-0-11230`
2023-09-08 13:48:21,223 INFO  [org.infinispan.LIFECYCLE] (jgroups-6,keycloak-0-11230) [Context=org.infinispan.CONFIG] ISPN100002: Starting rebalance with members [keycloak-1-40291, keycloak-0-11230], phase READ_OLD_WRITE_ALL, topology id 42
2023-09-08 13:48:21,260 INFO  [org.infinispan.LIFECYCLE] (non-blocking-thread--p2-t5) [Context=org.infinispan.CONFIG] ISPN100010: Finished rebalance with members [keycloak-1-40291, keycloak-0-11230], topology id 42

Первый кейс решен, keycloak развернут в режиме standalone-ha в k8s!

P. S. Если не уверены, потянет ли развернутое вами количество подов keycloak-HA всех ваших пользователей, то можете использовать проект самого keycloak-a под названием keycloak-benchmark для проведения тестов производительности.

Второй кейс. Админка на localhost и закрытие всех излишних путей для пользователей


Если мы развернем ingress keycloak-a, как в первом кейсе, то пользователям будут доступны все пути, в том числе и админка, а этого делать не рекомендуется, так как создаются дополнительные векторы атаки на систему аутентификации. В официальной документации keycloak можно посмотреть, какие пути достаточны для потока аутентификации пользователей.

Обрезать пути будем на reverse-proxy. Для работы стандартного потока аутентификации достаточно пути /realms/, но для примера указаны все пути, которые могут пригодиться и которые рекомендует keycloak. Измененный ingress будет выглядеть так:

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: keycloak
  annotations:
    # следующие 4 строки аннотаций настраивают Sticky Sessions на ingress-e
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-expires: "86400"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
    nginx.ingress.kubernetes.io/session-cookie-name: "keycloak-cookie"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
      - keycloak.examlple.ru
      secretName: web-tls
  rules:
  - host: keycloak.examlple.ru
    http:
      paths:      
      - path: /realms/
        pathType: Prefix
        backend:
          service:
            name: keycloak-http
            port:
              name: http
      - path: /resources/
        pathType: Prefix
        backend:
          service:
            name: keycloak-http
            port:
              name: http
      - path: /robots.txt
        pathType: ImplementationSpecific
        backend:
          service:
            name: keycloak-http
            port:
              name: http
      - path: /js/
        pathType: Prefix
        backend:
          service:
            name: keycloak-http
            port:
              name: http

После данных изменений доступа извне к админке не будет ни у кого. Но администраторам же надо конфигурировать сам IAM, а постоянно возвращать путь "/" для этих целей в ingress-e не хотелось бы. Можно использовать k8s port-forwarding, но админка все равно будет редиректить на внешний адрес keycloak-a, и в итоге мы получим страницу с «вечной» загрузкой административной консоли. Мы решили данный кейс, добавив в StatefulSet keycloak-a переменную KC_HOSTNAME_ADMIN_URL (поменяв редирект админки на localhost:9999/):

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: keycloak
  labels:
    app: keycloak-ha
spec:
  ...
  template:
    ...
    spec:
      ...
      containers:
          ...
          args:
            - start
          env:            
            - name: KC_HOSTNAME_ADMIN_URL
              value: "http://localhost:9999/"
            ...
      ...      

После этого выполним k8s port-forwarding на 9999/tcp:

kubectl port-forward -n keycloak services/keycloak-http 9999:8080

Далее заходим в браузере на localhost:9999 и с welcome-страницы переходим в админку.

Второй кейс решен, относительно безопасный доступ к админке только для админов открыт!

P.S. Я понимаю, что у читателей могут возникнуть замечания типа: администратор k8s и администратор IAM могут быть разные люди и администратору IAM придется давать права на port-forward. Но на практике во многих компаниях эту роль выполняют одни и те же люди, + настраивайте правильно RBAC в k8s и используйте impersonate для повышения привилегий.

P.P.S. Напоминаю, что у коллег по команде Security Services открыты вакансии пентестера (Penetration Testing Specialist) и аппсекера (Application Security Specialist), и попасть к нам можно всего за одно техническое собеседование!

А вот тут, в нашей игре про умный город, можно проверить свои знания по offensive и defensive.

Комментарии (6)


  1. toxella
    28.09.2023 04:18
    +2

    Можно для админки на ingress сделать nginx.ingress.kubernetes.io/whitelist-source-range, чтобы ограничить доступ только с IP-адресов подсети админов, если сеть достаточно сегментирована


  1. panablack Автор
    28.09.2023 04:18
    +1

    Да, это сработает, если по пути нет nat-ов) Идея интересная и применима не только в данном кейсе, спасибо)


  1. ggo
    28.09.2023 04:18

    а повесить админку на отдельный ингресс или игресс-контроллер?


    1. panablack Автор
      28.09.2023 04:18

      Цель была наоборот сделать так что бы админки не было на игресс-контроллере


      1. ggo
        28.09.2023 04:18

        зачем админку убираем с публичного домена - понимаю.
        зачем админку убираем совсем с ингресс-контроллера - не понимаю.

        ну да ладно...


        1. panablack Автор
          28.09.2023 04:18

          После всех настроек, админка кейклока нужна редко...поэтому если есть возможность ее закрыть, то лучше закрыть