如何使用 Envoy Gateway 在 API 网关侧基于 OIDC 实现单点登录?

本文详细介绍了如何配置 Envoy Gateway 使用 OIDC 实现单点登录。通过 Auth0 作为身份提供商,演示如何在 API 网关端实现安全、高效的单点登录,提升用户体验和系统安全性。

查看本文大纲

微服务中常见的认证方式详解这篇博客中我们介绍到了 OAuth 2.0 认证,该身份认证协议有多种实现方式,其中最流行的就是 OpenID Connect(OIDC)认证。OIDC 能够为用户提供身份验证和授权。本文将介绍如何使用 Envoy Gateway 在 API 网关级别实现 OIDC 认证。

Envoy Gateway 支持的认证方式

Envoy Gateway 是一个使用 Envoy 实现的高性能的 API 网关,支持多种认证方式来保护 API 和微服务:

  1. JWT 认证: 使用 JSON Web Tokens(JWT)进行认证。
  2. mTLS(双向 TLS): 使用双向 TLS 确保客户端和服务器之间的安全通信。
  3. HTTP Basic 认证: 使用用户名和密码进行基本认证。
  4. OIDC 认证: 使用 OpenID Connect 协议进行身份验证和授权。
  5. 外部认证:外部认证调用外部 HTTP 或 gRPC 服务来检查传入的 HTTP 请求是否经过认证。

本文重点介绍如何在 Envoy Gateway 中配置和使用 OIDC 认证从而在网关侧实现单点登录。

什么是 OIDC?

OpenID Connect(OIDC)是一个基于 OAuth 2.0 的身份验证协议。它允许客户端通过认证服务器验证用户身份,并获取有关用户的信息。

OIDC 认证流程如下图所示:

image
OIDC 认证流程图

OIDC 为 OAuth 2.0 增加了身份验证层,通过引入 ID Token 和标准化的 UserInfo Endpoint,使 OAuth 2.0 不仅能够用于授权,还可以用于安全地验证用户身份,从而实现单点登录(SSO)和用户身份信息的获取。

为什么要实现单点登录?

单点登录(Single Sign-On, SSO)是一种身份验证方式,它允许用户使用一个账户登录多个独立的应用系统,通过一次身份验证即可无缝访问所有相关应用,减少重复输入用户名和密码的麻烦,从而提升用户体验。SSO 集中管理用户身份和认证,增强了系统安全性,并简化了 IT 管理流程。

对于微服务架构,SSO 尤其重要,因为它在各个微服务之间实现统一的认证和授权,避免了每个服务单独实现身份验证逻辑的需求,减少了用户重复登录的麻烦,并提高了用户体验。集中管理的方式还可以统一应用安全策略,更有效地监控和响应安全事件,提升系统的整体安全性。同时,通过使用标准化的令牌(如 JWT),SSO 简化了微服务之间的身份验证过程,提高了开发效率,让开发人员能够专注于业务逻辑的实现。

示例:使用 Envoy Gateway 和 Auth0 的单点登录

接下来我们将使用 Auth0 作为身份供应商,演示如何使用 Envoy Gateway 在 API 网关端实现单点登录。

你可以在 Bilibili 上查看该示例演示。

基于 Auth0 实现单点登录

首先我们先说明下示例中 Envoy Gateway 基于 Auth0 实现单点登录的详细流程,如下图所示。

image
Enovy Gateway 基于 Auth0 实现单点登录的流程图

步骤说明:

  1. 用户访问网站:用户通过浏览器访问 https://www.example.com
  2. 请求转发:浏览器向 Envoy Gateway 发送 GET 请求。
  3. 检查 ID 令牌:Envoy Gateway 检查用户请求的 cookie 是否包含有效的 ID 令牌。
  4. 重定向到身份提供商:如果没有找到 ID 令牌,Envoy Gateway 将用户重定向到身份提供商(Identity Provider,IdP)的授权端点,在这里是 Auth0,关于 Auth0 如何实现 Login 的详细信息请查看 Auth0 文档
  5. 用户登录:用户在身份提供商的登录页面输入用户名和密码,并提交凭证进行登录。
  6. 获取授权码:用户成功登录后,身份提供商将用户重定向回 Envoy Gateway,并在 URL 中包含授权码。
  7. 交换 ID 令牌:Envoy Gateway 使用授权码向身份提供商请求 ID 令牌。
  8. 设置 Cookie:身份提供商返回 ID 令牌,Envoy Gateway 将其设置为用户的 cookie。
  9. 重定向:Envoy Gateway 将 URL 重定向到 https://www.example.com
  10. 验证 ID 令牌:Envoy Gateway 验证用户请求中的 ID 令牌。
  11. 路由请求:验证通过后,Envoy Gateway 将请求路由到后端应用(App)。

通过上述流程,Envoy Gateway 实现了单点登录功能。用户的 HTTP 请求在没有得到授权的情况下都会被转发单点登录页面。除了 Auth0 以外,Envoy Gateway 还支持多个身份提供商,如 Azure AD、Keycloak、Okta、OneLogin、Salesforce、UAA 等。

下面我们将按照时序图中的流程配置 Auth0 和 Envoy Gateway。

在 Auth0 上创建应用

请参考以下步骤在 Auth0 上设置一个 Regular Web Application:

  1. 访问 Auth0 并注册一个免费账户。
  2. 创建一个新的应用,并选择常规 Web 应用程序。
  3. 在应用设置中,记录或设置以下字段:
    • Domain{DOMAIN}
    • Client ID{CLIENT_ID}
    • Client Secret{CLIENT_SECRET}
    • Allowed Callback URLshttps://www.example.com/oauth2/myapp/callback
    • Allowed Logout URLshttps://www.example.com/myapp/logout

记住上面的 Auth0 字段,我们将用它们来配置 Envoy Gateway 的安全策略。

提示
这里的 Logout URL 不起实际作用,应为在我们的下面示例中的 backend 服务并没有实现 Auth0 的 logout 接口。我们只是按照习惯在此添加该字段,以待未来实现。

下面展示的是 Auth0 的配置页面截图,在设置好用户后并创建普通 Web 应用后,你只需要配置这两个地方。

image
Auth0 配置页面 1
image
Auth0 配置页面 2

以上就是 Auth0 的全部配置,接下来我们将安装和配置 Envoy Gateway。

安装 Envoy Gateway

参考 Envoy Gateway 快速开始在 minikube 上安装 Envoy Gateway:

minikube start --driver=docker --cpus=2 --memory=2g
helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.0.1 -n envoy-gateway-system --create-namespace
kubectl apply -f https://github.com/envoyproxy/gateway/releases/download/v1.0.1/quickstart.yaml -n default

参考安全网关,为 Envoy Gateway 配置 TLS:

# 创建根证书和私钥来签署证书
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -subj '/O=example Inc./CN=example.com' -keyout example.com.key -out example.com.crt

# 为 www.example.com 创建证书和私钥
openssl req -out www.example.com.csr -newkey rsa:2048 -nodes -keyout www.example.com.key -subj "/CN=www.example.com/O=example organization"
openssl x509 -req -days 365 -CA example.com.crt -CAkey example.com.key -set_serial 0 -in www.example.com.csr -out www.example.com.crt

# 将证书/密钥存储在 Secret 中
kubectl create secret tls example-cert --key=www.example.com.key --cert=www.example.com.crt

更新快速开始中创建的网关,使其包含 443 端口并引用 example-cert Secret 的 HTTPS Listener:

echo '[
  {
    "op": "add",
    "path": "/spec/listeners/-",
    "value": {
      "name": "https",
      "protocol": "HTTPS",
      "port": 443,
      "tls": {
        "mode": "Terminate",
        "certificateRefs": [
          {
            "kind": "Secret",
            "group": "",
            "name": "example-cert"
          }
        ]
      }
    }
  }
]' | kubectl patch gateway eg --type=json --patch-file /dev/stdin

创建 HTTPRoute,为 /myapp 端点增加一条到backend 服务的路由:

cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: myapp
spec:
  parentRefs:
  - name: eg
  hostnames: ["www.example.com"]
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /myapp
    backendRefs:
    - name: backend
      port: 3000
EOF

配置 Envoy Gateway 的 OIDC 认证

创建一个 Kubernetes Secret,用于存储 OAuth Client 的 Client Secret:

kubectl create secret generic auth0-client-secret --from-literal=client-secret=${CLIENT_SECRET}
注意
${CLIENT_SECRET} 替换成你的 Auth0 Client Secret。

创建一个安全策略(SecurityPolicy):

cat <<EOF | kubectl apply -f -
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: oidc-example
spec:
  targetRef:
    group: gateway.networking.k8s.io
    kind: Gateway
    name: eg
  oidc:
    provider:
      issuer: "https://${DOMAIN}"
    clientID: "${CLIENT_ID}"
    clientSecret:
      name: "auth0-client-secret"
    redirectURL: "https://www.example.com/myapp/oauth2/callback"
    logoutPath: "/myapp/logout"
EOF

注意事项

  • 此处的 issuer 应该填写成 Auth0 Domain。
  • redirectURL 的值需要出现在 Auth0 配置的 Allowed Callback URLs 中。
  • logoutPath 是必须的,即使其 URL 端点并为实现 logout 逻辑。

在这个示例中我们为 Envoy Gateway 网关设置了 OIDC,修改 targetRef 到 HTTPRoute,也可以为单个路由设置 OIDC。关于 ODIC 的具体配置,请参考 Envoy Gateway API 文档

验证单点登录:登入

www.example.com 添加到本地的 /etc/hosts 文件中:

echo "127.0.0.1 www.example.com" | sudo tee -a /etc/hosts

配置应用程序的端口转发,以便你可以在本地通过域名访问示例应用:

export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system --selector=gateway.envoyproxy.io/owning-gateway-namespace=default,gateway.envoyproxy.io/owning-gateway-name=eg -o jsonpath='{.items[0].metadata.name}')

sudo kubectl -n envoy-gateway-system port-forward service/${ENVOY_SERVICE} 443:443

现在在浏览器中访问 https://www.example.com,跳过证书风险提示,页面将跳转到 Auth0 的登录界面,如下图所示,选择使用 Google 账户登录。

image
登录界面

在登录完成后,浏览器将跳转会 https://www.example.com 页面,并展示 HTTP 请求结果,如下面的 JSON 代码所示。

{
  "path": "/",
  "host": "www.example.com",
  "method": "GET",
  "proto": "HTTP/1.1",
  "headers": {
    /*Omit*/
    "Authorization": [
      "Bearer eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaXNzIjoiaHR0cHM6Ly9kZXYtYXdoam15MzhnZzVxeng3dS51cy5hdXRoMC5jb20vIn0..IiH9LnxmnrGAVy-q.eQV_0Ssetw9mmrEaJNLlBowGJNX51awhh67WSejPrksuGU9e9-DcPJQqmR67ONFzTXWR6CFy4Rfgs4btsmEtvCtiNTCgrBHP90ddbOTg_pK31WnsQ7NThyRfGwoogSaAtK6hFrC2pxFaLj0XL7XvSPk-OaTzK1Zh1da1IM1cmWAWiBRc3nQiVWRDrExPo8-i5SawFe0jIcwytVSaRiX5Polyd3cZ7A7nlei-vDLCfj0HVzOO605nF7ED2dBSnZyev1sg14q598f3X2Vfhi2oJlnbiulGZIlpXgGbcPhzAJJxyEe6qpRpNg7Hbk8Ya-i8gUTwwNysrgm3.Zu5kD_6DzSfZPvwemttXYQ"
    ],
    "Cookie": [
      "OauthHMAC-167a6c5=RPdscXEBap0NeSIppJXoxkHt0qvMz4fNHXo2uvgDgIY=; OauthExpires-167a6c5=1716540771; BearerToken-167a6c5=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiaXNzIjoiaHR0cHM6Ly9kZXYtYXdoam15MzhnZzVxeng3dS51cy5hdXRoMC5jb20vIn0..IiH9LnxmnrGAVy-q.eQV_0Ssetw9mmrEaJNLlBowGJNX51awhh67WSejPrksuGU9e9-DcPJQqmR67ONFzTXWR6CFy4Rfgs4btsmEtvCtiNTCgrBHP90ddbOTg_pK31WnsQ7NThyRfGwoogSaAtK6hFrC2pxFaLj0XL7XvSPk-OaTzK1Zh1da1IM1cmWAWiBRc3nQiVWRDrExPo8-i5SawFe0jIcwytVSaRiX5Polyd3cZ7A7nlei-vDLCfj0HVzOO605nF7ED2dBSnZyev1sg14q598f3X2Vfhi2oJlnbiulGZIlpXgGbcPhzAJJxyEe6qpRpNg7Hbk8Ya-i8gUTwwNysrgm3.Zu5kD_6DzSfZPvwemttXYQ; IdToken-167a6c5=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFKWkxWbnNrai0tYmhsNlJzVm51OCJ9.eyJpc3MiOiJodHRwczovL2Rldi1hd2hqbXkzOGdnNXF6eDd1LnVzLmF1dGgwLmNvbS8iLCJhdWQiOiJUZzhlNWVoa0xKM2hka3cxTzREMTBQd21QeTcxZHZtdiIsImlhdCI6MTcxNjQ1NDM3MSwiZXhwIjoxNzE2NDkwMzcxLCJzdWIiOiJnb29nbGUtb2F1dGgyfDExMjc0NDc3OTAyMjMzMTA0ODY0MCIsInNpZCI6IjRlSjhDZnZuZjd5Mm1kaE94QXBTY0JiUEhjOS1rZUVLIn0.r9dwIy_HeiO5_I3UlohLkeRES5FGoxqQnwmcA00cA_kdc5mUxgeVopXIhBUjJnTKv7bOUVJvFw21ew4gqVRJfllDyG-s_XfhSW1-lEXmCc2bGYDtOzva6k2S_VRgyMKfG04_DWFuTgO_pLtix28aYq8cGzKJ_VglT_KgRhoktzJu4Js5iCv9JPnydRJmpvRJwX3tDv_Q3mmUSazaLkhOTdiBJFrGlS07qEzJ_iWANZgR8uDNhpXdmlcqpb3MZkkMulr5-jXIgEhBQKpw28tUiSlzh6EpAVuBH9T1w8bUmFRzCc6JPPamJRfflYW5onNgYHDfcU0RpvpsCHRHRAZbdA"
    ],
    /*Omit*/
    "User-Agent": [
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
    ],
    "X-Envoy-Internal": [
      "true"
    ],
    "X-Forwarded-For": [
      "10.244.0.51"
    ],
    "X-Forwarded-Proto": [
      "https"
    ],
    "X-Request-Id": [
      "c1e64057-c5c8-4fb2-a304-25c291eeed32"
    ]
  },
  "namespace": "default",
  "ingress": "",
  "service": "",
  "pod": "backend-55d64d8794-4qvgd"
}

此时通过 Chrome 浏览器的 Inspector - Application - Cookies 查看到 ID Token 如下图所示:

image
在 Chrome Inspector 中查看 ID Token

编写代码 Python 代码,validate_id_token.py,解析 ID Token 并验证其有效性:

import jwt
import requests
from jwt.algorithms import RSAAlgorithm
import argparse
import json
import base64

def base64url_decode(input):
    rem = len(input) % 4
    if rem > 0:
        input += '=' * (4 - rem)
    return base64.urlsafe_b64decode(input)

def get_signing_key(jwk_url, kid):
    jwks = requests.get(jwk_url).json()
    for jwk in jwks['keys']:
        if jwk['kid'] == kid:
            return RSAAlgorithm.from_jwk(jwk)
    raise Exception('Public key not found.')

def validate_token(token, audience, issuer, jwk_url):
    headers = jwt.get_unverified_header(token)
    kid = headers['kid']
    signing_key = get_signing_key(jwk_url, kid)

    decoded_token = jwt.decode(
        token,
        signing_key,
        algorithms=["RS256"],
        audience=audience,
        issuer=issuer
    )
    return decoded_token

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Validate a JWT token.')
    parser.add_argument('token', type=str, help='The JWT token to validate')
    args = parser.parse_args()

    token = args.token

    # 解析 token 的 payload 部分,提取 audience 和 issuer
    header, payload, signature = token.split('.')
    decoded_payload = base64url_decode(payload)
    payload_json = json.loads(decoded_payload)

    audience = payload_json['aud']
    issuer = payload_json['iss']
    jwk_url = f"{issuer}.well-known/jwks.json"

    try:
        decoded = validate_token(token, audience, issuer, jwk_url)
        print("Token is valid. Decoded payload:")
        for key, value in decoded.items():
            print(f"{key}: {value}")
    except Exception as e:
        print(f"Token validation failed: {e}")
下载代码 validate_id_token.py

安装依赖的包:

pip install pyjwt requests

运行代码:

python validate_id_token.py ${ID_TOKEN}

你将看到类似下面的输出:

Token is valid. Decoded payload:
iss: https://dev-awhjmy38gg5qzx7u.us.auth0.com/
aud: Tg8e5ehkLJ3hdkw1O4D10PwmPy71dvmv
iat: 1716470905
exp: 1716506905
sub: google-oauth2|112744779022331048640
sid: 4W_hQNJJ8ftDL8S3Cozp8GEu2Au4_e9N

通过该 ID Token 的值可以得出:

  • 该令牌由 Auth0 签发(iss 字段)。
  • 该令牌的受众是特定的应用或 API(aud 字段),对于 Auth0,这个值是 Client ID。
  • 令牌是在 2024 年 5 月 23 日 04:08:25 UTC 签发的(iat 字段),并将在 2024 年 5 月 23 日 14:08:25 UTC 过期(exp 字段)。
  • 令牌所代表的主体(用户)的唯一标识符是 google-oauth2|112744779022331048640sub 字段)。在这里,这个标识符表明使用 Google OAuth2 登录的用户
  • 会话 ID 是 4W_hQNJJ8ftDL8S3Cozp8GEu2Au4_e9Nsid 字段),用于会话管理。

验证单点登录:登出

由于我们的示例应用中没有实现 Auth0 的 Logout 逻辑,所以我们需要通过 HTTP 请求明确告知 Auth0 要 logout,在浏览器中访问该 URL:

https://${DOMAIN}/v2/logout?client_id=${CLIENT_ID}

请将 ${DOMAIN}${CLIENT_ID 修改为你的 Auth0 应用程序的配置项。关于 Auth0 如何登出 OIDC 端点的详细说明请查看 Auth0 文档

登出后,页面将再次跳转到登录页面,在登录后,页面将重定向到 https://www.example.com

总结

通过以上步骤,你可以在 Envoy Gateway 中实现 OIDC 认证,确保 API 的安全性。这种方法不仅能提供灵活的身份验证机制,还能简化应用程序的身份管理。通过集成 Auth0 等身份提供商,Envoy Gateway 可以轻松实现单点登录,提升用户体验和系统安全性。未来,你可以根据需求进一步配置和优化 Envoy Gateway,充分利用其强大的认证和授权功能,以满足更复杂的安全要求和业务需求。

参考

最后更新于 2024/06/22