Đăng vào

Thiết lập SSO cho Ingress Traefik sử dụng Oauth2 Proxy

Bài viết này hướng dẫn cách bảo vệ một static webapp bằng SSO (Single Sign-On) sử dụng OAuth2 Proxy và Microsoft Entra ID (trước đây là Azure AD) làm identity provider, với Traefik làm Ingress Controller trên Kubernetes.

Giới thiệu

Traefik là gì?

Traefik là một reverse proxyIngress Controller hiện đại, mã nguồn mở được thiết kế đặc biệt cho các hệ thống container và microservices. Nó hoạt động như một "cánh cổng" (gateway) để quản lý lưu lượng truy cập vào các ứng dụng chạy trên Kubernetes.

  • Ưu điểm của Traefik trong môi trường Kubernetes:
    • Tự động phát hiện service
    • Hỗ trợ nhiều protocol (HTTP, TCP, UDP)
    • Middleware mạnh mẽ

Kiến trúc Traefik trên Kubernetes có thể tóm lược lại như dưới đây

┌─────────────────────────────────────────────────┐
Kubernetes Cluster│                                                 │
│  ┌───────────┐    ┌───────────┐    ┌──────────┐ │
│  │  Ingress  │    │  Service  │    │   Pods   │ │
│  │  Traefik  │◄───┤  (LB)     │◄───┤ (App)    │ │
│  └───────────┘    └───────────┘    └──────────┘ │
│                                                 │
└─────────────────────────────────────────────────┘

OAuth2 Proxy là gì?

  • Là reverse proxy cung cấp authentication sử dụng OAuth2
  • Hỗ trợ nhiều provider: Google, GitHub, Azure AD,...
  • Chức năng chính:
  • Xác thực người dùng
  • Lưu session
  • Chuyển header thông tin user

Mình đã có nhiều bài viết về việc tích hợp SSO sử dụng OAuth2 Proxy cũng như Microsoft EntraID:

Tích hợp SSO cho một static webapp

Trong quá trình làm việc, mình gặp phải bài toán là phải phân cấp, phân quyền cho tài liệu nội bộ của dự án, tài liệu kiểu Docs as Code sử dụng sphinx, mkdocs để build ra các static webapp html, css,... và tích hợp với account Microsoft của dự án.

Hiện tại document đã được host trên Kuberentes cluster, cluster này sử dụng Traefik.

Để dễ minh hoạ mình lấy một static web đơn giản là echo server.

Static webapp echo server

Dưới đây là manifest để khởi tạo: namespace, deploy, service:

echoserver.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: echoserver
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echoserver
  namespace: echoserver
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echoserver
  template:
    metadata:
      labels:
        app: echoserver
    spec:
      containers:
      - image: docker.io/ealen/echo-server:latest
        imagePullPolicy: IfNotPresent
        name: echoserver
        ports:
        - containerPort: 80
        env:
        - name: PORT
          value: "80"
---
apiVersion: v1
kind: Service
metadata:
  name: echoserver
  namespace: echoserver
spec:
  ports:
    - port: 80
      targetPort: 80
      protocol: TCP
  type: ClusterIP
  selector:
    app: echoserver

Tiếp theo mình sẽ thiết lập TLS, để cho đơn giản mình lấy ví dụ sử dụng Letsencrypt. Hiển nhiên trong môi trường production mình sẽ dử dụng SSL của công ty, cái này chỉ để ví dụ

echoserver-tls.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: acme
spec:
  acme:
    # Use staging by default.
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: acme
    solvers:
      - http01:
          ingress:
            class: traefik
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: test-domain
  namespace: echoserver
spec:
  secretName: test-nvtienanh       # <===  Name of secret where the generated certificate will be stored.
  dnsNames:
    - "test.nvtienanh.info"
  issuerRef:
    name: acme
    kind: ClusterIssuer

Cuối cùng đây là ingress traefik (khi chưa tích hợp SSO)

echoserver-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echoserver
  namespace: echoserver
  annotations:
    kubernetes.io/ingress.class: traefik
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
  ingressClassName: traefik
  rules:
    - host: test.nvtienanh.info
      http:
        paths:
        - path: /
          pathType: ImplementationSpecific
          backend:
            service:
              name: echoserver
              port:
                number: 80
  tls:
    - secretName: test-nvtienanh # <=== Use the name defined in Certificate resource
      hosts:
        - test.nvtienanh.info

Khi bạn access vào nó sẽ trả về kiểu

{
  "host": {
    "hostname": "test.nvtienanh.info",
    "ip": "::ffff:10.1.66.82",
    "ips": []
  },
  "http": {
    "method": "GET",
    "baseUrl": "",
    "originalUrl": "/",
    "protocol": "http"
  },
  "request": {
    "params": {
      "0": "/"
    },
    "query": {

    },
    "cookies": {

    },
    "body": {

    },
    "headers": {
      "host": "test.nvtienanh.info",
      "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0",
      "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
      "accept-encoding": "gzip, deflate, br, zstd",
      "accept-language": "en-US,en;q=0.9,vi;q=0.8,nl;q=0.7",
      "cache-control": "max-age=0",
      "dnt": "1",
      "if-none-match": "W/\"3065-JVAYDNERjqvhkiqwzxjw20qHMcU\"",
      "priority": "u=0, i",
      "sec-ch-ua": "\"Microsoft Edge\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
      "sec-ch-ua-mobile": "?0",
      "sec-ch-ua-platform": "\"Windows\"",
      "sec-fetch-dest": "document",
      "sec-fetch-mode": "navigate",
      "sec-fetch-site": "none",
      "sec-fetch-user": "?1",
      "upgrade-insecure-requests": "1",
      "x-forwarded-for": "10.233.64.31",
      "x-forwarded-host": "test.nvtienanh.info",
      "x-forwarded-port": "443",
      "x-forwarded-proto": "https",
      "x-forwarded-server": "traefik-5b758dfc99-8x7mw",
      "x-real-ip": "10.233.64.31"
    }
  },
  "environment": {
    "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
    "TERM": "xterm",
    "HOSTNAME": "echoserver-fbd589788-lchwz",
    "PORT": "80",
    "KUBERNETES_PORT_443_TCP_PORT": "443",
    "ECHOSERVER_PORT": "tcp://10.233.52.18:80",
    "ECHOSERVER_PORT_80_TCP_PORT": "80",
    "ECHOSERVER_PORT_80_TCP_ADDR": "10.233.52.18",
    "KUBERNETES_SERVICE_HOST": "10.233.0.1",
    "KUBERNETES_SERVICE_PORT_HTTPS": "443",
    "KUBERNETES_PORT": "tcp://10.233.0.1:443",
    "KUBERNETES_PORT_443_TCP_PROTO": "tcp",
    "KUBERNETES_SERVICE_PORT": "443",
    "ECHOSERVER_SERVICE_HOST": "10.233.52.18",
    "ECHOSERVER_SERVICE_PORT": "80",
    "KUBERNETES_PORT_443_TCP": "tcp://10.233.0.1:443",
    "KUBERNETES_PORT_443_TCP_ADDR": "10.233.0.1",
    "ECHOSERVER_PORT_80_TCP": "tcp://10.233.52.18:80",
    "ECHOSERVER_PORT_80_TCP_PROTO": "tcp",
    "NODE_VERSION": "20.11.0",
    "YARN_VERSION": "1.22.19",
    "HOME": "/root"
  }
}

Thiết lập Traefik

Mình giả sử rằng cluster của bạn đã được thiết lập sẵn Traefik và có môt ingressClass là traefik. Lưu ý rằng bạn nên thiết lập allowCrossNamespace cho Traefik:

helm upgrade --install traefik traefik/traefik --namespace=traefik --create-namespace \
    .... \
    --set providers.kubernetesCRD.allowCrossNamespace=true \
    ...

Thiết lập Oauth2 Proxy

Có nhiều cách cài đặt Oauth2 Proxy trên Kubernetes, thông thường là dùng Helm chart dưới đây để đơn giản mình dùng trực tiếp manifest, nếu bạn cài bằng Helm thì nhớ cập nhật lại values.yaml cho phù hợp.

Đồng thời nhớ cập nhật <CLIENT_SECRET>, <CLIENT_ID><TENANT_ID> cho đúng với EntraID của bạn

oauth2-proxy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/name: oauth2-proxy
  name: oauth2-proxy
  namespace: echoserver
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: oauth2-proxy
  template:
    metadata:
      labels:
        app.kubernetes.io/name: oauth2-proxy
    spec:
      containers:
        - args:
            - --provider=entra-id
            - --cookie-name=_proxycookie
            - --email-domain=*
            - --upstream=static://200
            - --http-address=0.0.0.0:4180
            - --oidc-issuer-url=https://login.microsoftonline.com/<TENANT_ID>/v2.0
            - --set-xauthrequest=true
            - --cookie-csrf-expire=9m
            - --cookie-csrf-per-request=false
            - --cookie-expire=1h
            - --cookie-refresh=59m
            - --set-authorization-header=true
            - --whitelist-domain=.nvtienanh.info:*
            - --scope=openid email profile
            - --reverse-proxy=true
            - --skip-provider-button=true
            - --pass-user-headers=true
          env:
            - name: OAUTH2_PROXY_CLIENT_ID
              value: <CLIENT_ID>
            - name: OAUTH2_PROXY_CLIENT_SECRET
              value: <CLIENT_SECRET>
            - name: OAUTH2_PROXY_COOKIE_SECRET
              value: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" # Replace with your own secret
          image: quay.io/oauth2-proxy/oauth2-proxy:v7.9.0
          imagePullPolicy: IfNotPresent
          name: oauth2-proxy
          ports:
            - containerPort: 4180
              protocol: TCP
          resources:
            limits:
              cpu: 200m
              memory: 128Mi
            requests:
              cpu: 100m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app.kubernetes.io/name: oauth2-proxy
  name: oauth2-proxy
  namespace: echoserver
spec:
  ports:
    - name: http
      port: 4180
      protocol: TCP
      targetPort: 4180
  selector:
    app.kubernetes.io/name: oauth2-proxy

Thiết lập Traefik Middleware

Dưới đây là cấu hình middleware của mình:

middleware.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oauth-auth-redirect
  namespace: echoserver
spec:
  forwardAuth:
    address: http://oauth2-proxy.echoserver.svc.cluster.local:4180/
    trustForwardHeader: true
    authResponseHeaders:
      - X-Auth-Request-User
      - Set-Cookie
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: oauth-errors
  namespace: echoserver
spec:
  errors:
    status:
      - "401-403"
    service: 
      name: oauth2-proxy
      port: 4180
    query: "/oauth2/sign_in"

Bước cuối cùng, chúng ta sẽ cập nhật lại cấu hình Ingress

Thiết lập Ingress Traefik

Bây giờ chúng ta sẽ thêm traefik.ingress.kubernetes.io/router.middlewares vào annotations của ingress

sso-ingress.yaml

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echoserver
  namespace: echoserver
  annotations:
    kubernetes.io/ingress.class: traefik
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.middlewares: "echoserver-oauth-errors@kubernetescrd,echoserver-oauth-auth-redirect@kubernetescrd"
spec:
  ingressClassName: traefik
  rules:
    - host: test.nvtienanh.info
      http:
        paths:
        - path: /
          pathType: ImplementationSpecific
          backend:
            service:
              name: echoserver
              port:
                number: 80
  tls:
    - secretName: test-nvtienanh # <=== Use the name defined in Certificate resource
      hosts:
        - test.nvtienanh.info
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: traefik
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
    traefik.ingress.kubernetes.io/router.middlewares: "echoserver-oauth-errors@kubernetescrd"
  name: oauth2-proxy
  namespace: echoserver
spec:
  ingressClassName: traefik
  tls:
    - secretName: test-nvtienanh # <=== Use the name defined in Certificate resource
      hosts:
        - test.nvtienanh.info
  rules:
    - host: test.nvtienanh.info
      http:
        paths:
          - path: /oauth2
            pathType: ImplementationSpecific
            backend:
              service:
                name: oauth2-proxy
                port:
                  number: 4180

Bây giờ khi bạn truy cập vào, nó sẽ đá qua trang login vào trang login của Microsoft, sau đó sẽ redirect lại trang ứng dụng của bạn, với ứng dụng echoserver kết quả trả về sẽ có dạng:

{
  "host": {
    "hostname": "test.nvtienanh.info",
    "ip": "::ffff:10.233.66.81",
    "ips": []
  },
  "http": {
    "method": "GET",
    "baseUrl": "",
    "originalUrl": "/",
    "protocol": "http"
  },
  "request": {
    "params": {
      "0": "/"
    },
    "query": {

    },
    "cookies": {
      "_proxycookie_0": "XBi0nCRa0iulheY2YpUFB-",
      "_proxycookie_1": "keKeqRgOr1uZ1jwi3RO3cfZVaFLG-"
    },
    "body": {

    },
    "headers": {
      "host": "test.nvtienanh.info",
      "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0",
      "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
      "accept-encoding": "gzip, deflate, br, zstd",
      "accept-language": "en-US,en;q=0.9,vi;q=0.8,nl;q=0.7",
      "cookie": "_proxycookie_0=XBi0nCRa0iulheY2YpUFB-pfSu4DUN_20GXtgE_T9bhSa7H8T2oGOZa-",
      "dnt": "1",
      "priority": "u=0, i",
      "sec-ch-ua": "\"Microsoft Edge\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
      "sec-ch-ua-mobile": "?0",
      "sec-ch-ua-platform": "\"Windows\"",
      "sec-fetch-dest": "document",
      "sec-fetch-mode": "navigate",
      "sec-fetch-site": "none",
      "sec-fetch-user": "?1",
      "upgrade-insecure-requests": "1",
      "x-auth-request-user": "afq_UjrKE5DWf-cnqKem0q_6MZ9wUBdRWSjRtRLTVnE",
      "x-forwarded-for": "10.233.64.31",
      "x-forwarded-host": "test.nvtienanh.info",
      "x-forwarded-port": "443",
      "x-forwarded-proto": "https",
      "x-forwarded-server": "traefik-5b758dfc99-8x7mw",
      "x-real-ip": "10.233.64.31"
    }
  },
  "environment": {
    "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
    "TERM": "xterm",
    "HOSTNAME": "echoserver-fbd589788-lchwz",
    "PORT": "80",
    "KUBERNETES_PORT_443_TCP_PORT": "443",
    "ECHOSERVER_PORT": "tcp://10.233.52.18:80",
    "ECHOSERVER_PORT_80_TCP_PORT": "80",
    "ECHOSERVER_PORT_80_TCP_ADDR": "10.233.52.18",
    "KUBERNETES_SERVICE_HOST": "10.233.0.1",
    "KUBERNETES_SERVICE_PORT_HTTPS": "443",
    "KUBERNETES_PORT": "tcp://10.233.0.1:443",
    "KUBERNETES_PORT_443_TCP_PROTO": "tcp",
    "KUBERNETES_SERVICE_PORT": "443",
    "ECHOSERVER_SERVICE_HOST": "10.233.52.18",
    "ECHOSERVER_SERVICE_PORT": "80",
    "KUBERNETES_PORT_443_TCP": "tcp://10.233.0.1:443",
    "KUBERNETES_PORT_443_TCP_ADDR": "10.233.0.1",
    "ECHOSERVER_PORT_80_TCP": "tcp://10.233.52.18:80",
    "ECHOSERVER_PORT_80_TCP_PROTO": "tcp",
    "NODE_VERSION": "20.11.0",
    "YARN_VERSION": "1.22.19",
    "HOME": "/root"
  }
}