如何用OAuth2代理向任何应用程序添加授权信息

1,472 阅读8分钟

更新一个应用程序以使用OAuth 2.0并不需要很复杂。大多数情况下,你的语言或框架已经有一个OAuth库。有时,情况并非如此,你需要找到一个替代品。在这篇文章中,我将介绍如何设置和使用OAuth2代理来保护你的应用程序,而不需要修改任何代码

OAuth2代理是一个反向代理,它位于你的应用程序前面,为你处理OpenID Connect/OAuth 2.0的复杂性;进入你的应用程序的请求已经被授权了

一个没有安全保障的应用程序

首先,我们需要一个应用程序。你可以使用任何Web应用程序,但在这篇文章中,我将坚持使用一个Java Spring Boot应用程序,它将呼应入站的HTTP请求的细节。呼应请求信息将有助于可视化OAuth2代理添加的额外HTTP头信息,如用户的电子邮件地址。

你可以从GitHub上抓取该项目:

git clone https://github.com/oktadev/okta-oauth2-proxy-example.git -b start
cd okta-oauth2-proxy-example
如果你想看已完成的项目,请查看main 分支,而不是。

如果你对这个Java应用的细节感兴趣,可以看一下EchoApplication 类。它包含一个单一的端点,将处理所有的请求并将请求的内容 "转储 "为JSON对象:

@RestController
static class EchoRestController {
    @RequestMapping("/**")
    Map<String, Object> echo(HttpServletRequest request,
                             @RequestHeader HttpHeaders headers,
                             @RequestBody(required = false) Map<String, Object> body) {

        Instant now = Instant.now();
        Cookie[] cookies = request.getCookies();

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("clientIpAddress", request.getRemoteAddr());
        result.put("cookies", cookies == null ? emptyList()
                                              : Arrays.stream(cookies).toList());
        result.put("headers", headers.toSingleValueMap()); // simplify json response
        result.put("httpVersion", request.getProtocol());
        result.put("method", request.getMethod());
        result.put("body", body);
        result.put("queryString", request.getQueryString());
        result.put("startedDateTime", now);
        result.put("url", request.getRequestURL());

        return result;
    }
}

如果你是一个Java爱好者,你可以通过运行./mvnw spring-boot:run 来启动该应用程序。其他所有人都可以运行以下Docker命令:

docker build --tag echo-app .
docker run -p 8080:8080 echo-app

一旦应用程序运行,打开你的浏览器或用HTTPie在终端访问网络应用:

http localhost:8080/echo
当使用HTTPie时,你可以省略 "localhost",直接输入http :8080/echo!😎

你会看到一个看起来像这样的响应:

{
    "body": null,
    "clientIpAddress": "172.17.0.1",
    "cookies": [],
    "headers": {
        "accept": "*/*",
        "accept-encoding": "gzip, deflate",
        "connection": "keep-alive",
        "host": "localhost:8080",
        "user-agent": "HTTPie/3.2.1"
    },
    "httpVersion": "HTTP/1.1",
    "method": "GET",
    "queryString": null,
    "startedDateTime": "2022-06-24T18:52:07.853663255Z",
    "url": "http://localhost:8080/echo"
}

现在我们有了一个工作的应用程序,让我们来保护它吧

在进入下一节之前,用Ctrl+C停止应用程序,不要担心;我们将在一分钟内重新启动它。

设置Okta

为了用OAuth 2.0保护我们的应用程序,我们需要一个OAuth身份提供者(IdP)。任何具有OpenID Connect(OIDC)功能的服务器都可以使用,例如Auth0Keycloak,但这是一个Okta博客,所以让我们使用Okta。

如果你还没有,你将需要一个免费的Okta开发者账户。安装Okta CLI,从项目目录中,运行okta start ,注册一个新的账户,配置这个应用程序

如果您已经有一个Okta账户,请先运行okta login

Okta CLI是做什么的?

Okta CLI将在您的Okta Org中创建一个OIDC网络应用。它将添加所需的重定向URI并授予Everyone组的访问权限。当它完成后,您会看到如下输出:

Okta application configuration has been written to: .env

运行cat .env (或Windows上的type .env ),查看你的应用程序的发行者和证书:

ISSUER=https://dev-133337.okta.com/oauth2/default
CLIENT_ID=0oab8eb55Kb9jdMIr5d6
CLIENT_SECRET=NEVER-SHOW-SECRETS

设置OAuth2代理

有两种方法可以使用OAuth2代理:通过它直接路由你的流量,或者与Nginxauth_request 指令一起使用。如果可能的话,我建议通过Nginx路由流量,但我将在下文中介绍这两种选择并解释我的建议。

为了减少活动部件的数量,我将在第一个例子中不使用Nginx;我们所有的网络流量都将通过OAuth2 Proxy流动。访问echo web应用程序的用户将被重定向到Okta进行登录。一旦他们登录了,OAuth2代理将设置一个会话cookie。在以后的请求中,OAuth2代理将验证会话,然后再将请求传递给echo web应用程序。

在OAuth术语中,OAuth2代理作为 "客户端",处理OAuth协议的细节,(在这种情况下,授权码授予)。
TL;DR - 将用户重定向到OAuth IdP的登录页面,并处理一个 "回调 "路线,将他们返回到应用程序。

这个例子要淘汰手动使用docker run 命令;切换到使用docker compose ,启动echo web-app和oauth2-proxy。

让我们从简单的开始,随着我们的进展增加复杂性。用oauth2-proxy和上面的web应用创建一个docker-compose.yml

version: "3.7"
services:

  web-app: (1)
    build: .

  oauth2-proxy:
    image: bitnami/oauth2-proxy:7.3.0
    command:
      - --http-address
      - 0.0.0.0:4180 (2)
    environment:
      OAUTH2_PROXY_UPSTREAMS: http://web-app:8080/ (3)
      OAUTH2_PROXY_PROVIDER_DISPLAY_NAME: Okta
      OAUTH2_PROXY_PROVIDER: oidc (4)
      OAUTH2_PROXY_OIDC_ISSUER_URL: ${ISSUER}
      OAUTH2_PROXY_CLIENT_ID: ${CLIENT_ID}
      OAUTH2_PROXY_CLIENT_SECRET: ${CLIENT_SECRET}
      OAUTH2_PROXY_PASS_ACCESS_TOKEN: true (5)
      OAUTH2_PROXY_EMAIL_DOMAINS: '*' (6)
      OAUTH2_PROXY_REDIRECT_URL: http://localhost:4180/oauth2/callback (7)
      OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET} (8)

    ports:
      - 4180:4180 (9)
1在当前目录下构建并运行Dockerfile。
2听取端口4180
3将经过验证的请求代理给Java web-app容器。
4OIDC客户信息(发行者、客户ID和客户秘密),这些值在.env 文件中定义。
5可选地,将访问权传递给web-app。
6允许所有的电子邮件域,除非你使用社会认证提供商,你要在你的身份识别程序中管理这个,而不是在你的应用程序中。
7将重定向URL设置为http ,默认为https
8打开.env 文件,将这个变量设置为一个随机的32字节base64字符串openssl rand -base64 32 | tr — '+/' '-_'
9暴露端口4180

通过运行来启动一切:

docker compose up

现在打开你的浏览器到http://localhost:4180/echo ,你将被重定向到一个带有登录按钮的页面。点击该按钮,你将被重定向到 "echo "应用程序,你应该看到关于新认证的请求的信息!

如果您已经登录了您的Okta账户,请打开一个隐身/私人浏览器,查看完整的登录流程。

很好,应用程序现在是安全的,但我们还有一些事情需要清理:

  • 所有的会话状态都存储在一个cookie中。

  • 最初的双重重定向登录页面必须删除。

  • 我们还没有谈到API访问。

这前两个问题可以通过对OAuth2代理配置的一些更新来解决。编辑docker-compose.yml 文件。

       OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET}
+      OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: true (1)
+      OAUTH2_PROXY_COOKIE_NAME: SESSION (2)
+      OAUTH2_PROXY_COOKIE_SAMESITE: lax (3)
+      OAUTH2_PROXY_SESSION_STORE_TYPE: redis (4)
+      OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://redis
    ports:
      - 4180:4180
+    depends_on:
+      - redis
+
+  redis:(5)
+    image: redis:7.0.2-alpine3.16
+    volumes:
+      - cache:/data (6)
+
+volumes:
+  cache:
+    driver: local
1跳过默认的登录页面,直接重定向到IdP。
2默认情况下,cookie的名称是_oauth2_proxy ;将其改为SESSION
3将cookie的同一站点策略设置为lax ;来自OAuth IdP的重定向将需要会话cookie。
4使用Redis来存储会话信息。
5启动一个Redis容器。
6在重新启动之间保持Redis的数据。

停止docker-compose进程(Ctrl+C),然后再次启动它。

docker compose up

再次打开浏览器到http://localhost:4180/echo ,并打开网络标签,你会看到重命名的、现在更小的SESSION cookie。

可以在这里停止,但你不应该。我们仍然有一些问题。API客户端不被支持,而且我们还没有谈到注销的问题。

对于下一节,你将需要一个访问令牌。你可以使用你上次请求的x-access-token 头的访问令牌。打开你的终端,设置一个环境变量:export TOKEN={your-token-value}

REST API客户端

在这篇文章中,我将认为任何设置了Authorization HTTP头的客户端都是API客户端。例如:Authorization: Bearer {access_token_here}

API客户端可能无法处理重定向响应,但期望返回一个40x 状态代码。

让我们退一步,把OAuth2代理配置成一个OAuth资源服务器,接受JWT访问令牌。这可能是你对某些应用的全部需要,但如果你需要同时支持浏览器和API客户端,请继续阅读,我们将在下一节中达到这个目的。

这很常见,但并不要求OAuth 2.0的访问令牌必须是JWT。如果你使用的是不同的OAuth IdP,在继续之前请仔细检查他们是否支持JWT。

docker-compose.yml ,将环境变量修剪到REST API所需的最低限度:

...
    environment:
      OAUTH2_PROXY_UPSTREAMS: http://web-app:8080/
      OAUTH2_PROXY_PROVIDER: oidc (1)
      OAUTH2_PROXY_EMAIL_DOMAINS: '*'
      OAUTH2_PROXY_SKIP_JWT_BEARER_TOKENS: true (2)
      OAUTH2_PROXY_OIDC_EMAIL_CLAIM: sub (3)
      OAUTH2_PROXY_OIDC_ISSUER_URL: ${ISSUER} (4)
      OAUTH2_PROXY_CLIENT_ID: api://default (5)
      OAUTH2_PROXY_SET_XAUTHREQUEST: true
      OAUTH2_PROXY_CLIENT_SECRET: this_value_is_required_but_not_used (6)
      OAUTH2_PROXY_COOKIE_SECRET: NOT_USED_BUT_REQUIRED_VALUE_32b_ (7)
...
1我们实际上没有使用任何OIDC流程,但这仍然是必需的。
2也许是一个命名不当的变量,它告诉oauth2-proxy ,验证JWT访问令牌,并 "跳过 "寻找OAuth 2.0会话。
3从访问令牌中的sub 读取用户的电子邮件。
4使用相同的发行者URL,JWKS端点将通过OIDC发现元数据自动查找。
5"client-id "实际上是受众aud 索赔,而不是特定客户的ID(多个API "客户 "可能访问同一个REST API)。
6没有 "client-secret",但它是一个必填字段...
7与cookie secret相同,这些流量不使用cookie,但该字段是必须的。

重新启动服务。(停止,然后再次运行docker compose up )。

使用你在上一节设置的访问令牌环境变量,运行这个:

http :4180/echo "Authorization: Bearer ${TOKEN}"

棒极了!现在你的应用程序对REST客户端来说是安全的了!

没那么快;现在我们的浏览器客户端不能正常工作了客户端ID和密码不正确,这意味着用户将无法登录。我们可以用Nginx来解决这两个问题。

添加Nginx来路由流量

在混合中添加另一个反向代理似乎是过分的;一个请求要进入应用程序,需要先通过Nginx和OAuth2代理。然而,你可能已经使用Nginx来进行负载平衡、TLS终止或其他入口问题。

虽然我们可以像上图那样,将我们的流量通过两个代理,但我将使用Nginxauth_request 指令来代替。Nginx将使用原始请求头(包括任何cookies和Authorization 头)向OAuth2 Proxy的/oauth2/auth 端点发出REST请求。如果请求是有效的,OAuth2 Proxy将回应一个202 状态代码,否则就是401

这个设置使用的请求数与上图相同,但在如何将请求路由到上游Web应用上提供了额外的灵活性。

配置Nginx

跳回docker-compose.yml ,为Nginx添加一个新的service

...
  nginx:
    image: nginx:1.21.6-alpine
    depends_on:
      - oauth2-proxy
      - web-app
    volumes:
      - ./nginx-default.conf.template:/etc/nginx/templates/default.conf.template
    ports:
      - 80:80

接下来,创建一个nginx-default.conf.template 文件。 这个代码块有点复杂,请务必阅读注释。

server {
    listen 80;
    server_name _;

    location = /oauth2/auth {
        internal; (1)
        proxy_pass       http://oauth2-proxy:4180;
        proxy_set_header Host             $host;
        proxy_set_header X-Real-IP        $remote_addr;
        proxy_set_header X-Scheme         $scheme;
        # nginx auth_request includes headers but not body
        proxy_set_header Content-Length   "";
        proxy_pass_request_body           off;
    }

    location / {
        auth_request /oauth2/auth; (2)

        auth_request_set $email  $upstream_http_x_auth_request_email; (3)
        proxy_set_header X-Email $email;
        auth_request_set $user  $upstream_http_x_auth_request_user;
        proxy_set_header X-User  $user;
        auth_request_set $token  $upstream_http_x_auth_request_access_token;
        proxy_set_header X-Access-Token $token;
        auth_request_set $auth_cookie $upstream_http_set_cookie;
        add_header Set-Cookie $auth_cookie;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host $host:80;
        proxy_set_header X-Forwarded-Port 80;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-Proto http;

        proxy_http_version 1.1; (4)
        proxy_pass http://web-app:8080/; (5)
    }
}
1不要把这个路由暴露给外部客户。
2Nginx向oauth2-proxy REST API发出请求,以验证该请求的授权。
3添加从认证请求返回的头信息。
4如果没有设置,HTTP 1.0是默认的。
5向网络应用程序发送经过验证的请求。

重新启动docker容器,并验证HTTPie的一切工作(确保你现在使用的是端口80 )。

http localhost/echo "Authorization: Bearer ${TOKEN}"

如果你删除或改变Authorization 头,将返回一个401 。浏览器请求现在也将返回一个401!

几乎完成了!我们仍然需要让API客户端和浏览器都能正常工作,并处理签出请求。

通过Nginx路由所有的流量

通过Nginx发送所有的流量,还有一个好处,就是让你控制OAuth2代理端点的暴露方式。例如,上一节将/oauth2/auth 路由标记为 "内部",所以只有auth_requst 指令可以使用它。

nginx-default.conf.template ,添加几个新的location 部分来暴露其他/oauth2 端点。第一个location 将处理与OAuth 2.0相关的请求,如重定向回调。第二个将配置签出端点,使其只接受POST请求。(这可以防止一个无赖的GET请求结束用户的会话)。

    location /oauth2/ {
        proxy_pass       http://oauth2-proxy:4180; (1)
        proxy_set_header Host                    $host;
        proxy_set_header X-Real-IP               $remote_addr;
        proxy_set_header X-Scheme                $scheme;
    }

    location = /oauth2/sign_out { (2)
        # Sign-out mutates the session, only allow POST requests
        if ($request_method != POST) {
            return 405;
        }

        proxy_pass       http://oauth2-proxy:4180;
        proxy_set_header Host                    $host;
        proxy_set_header X-Real-IP               $remote_addr;
        proxy_set_header X-Scheme                $scheme;
    }
...
1将OAuth回调和注销请求发送到oauth2-proxy。
2只允许向签出端点发出POST请求。
签出端点不使用CSRF令牌。TODO:链接到Alisa关于这个话题的帖子。

最后一个改动,更新location / 部分,以重定向到所有非API客户的登录页面。

location / {
        auth_request /oauth2/auth;

        # if the authorization header was set (i.e. `Authorization: Bearer {token}`)
        # assume API client and do NOT redirect to login page
        if ($http_authorization = "") {
            error_page 401 = /oauth2/start;
        }
...

配置OAuth2代理以支持API和浏览器客户端

有时,一个应用程序需要处理来自浏览器和其他API客户端的请求。 在这种情况下,应用程序既作为OAuth客户端,又作为资源服务器。OAuth2代理可以被配置为支持这两种类型的应用程序。然而,你可能已经注意到,有几个OAuth2 Proxy的配置值是超载的;例如,"客户端ID "既被用作OAuth客户端的ID,又被用作受众的JWT值。幸运的是,有一个变通的办法!下面是最后的注释docker-compose.yml

version: "3.7"

services:

  web-app:
    build: .

  oauth2-proxy:
    image: bitnami/oauth2-proxy:7.3.0
    depends_on:
      - redis
    command:
      - --http-address
      - 0.0.0.0:4180
    environment:
      OAUTH2_PROXY_EMAIL_DOMAINS: '*' (1)
      OAUTH2_PROXY_PROVIDER: oidc (2)
      OAUTH2_PROXY_PROVIDER_DISPLAY_NAME: Okta
      OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: true (3)
      OAUTH2_PROXY_REDIRECT_URL: http://localhost/oauth2/callback (4)

      OAUTH2_PROXY_OIDC_ISSUER_URL: ${ISSUER} (5)
      OAUTH2_PROXY_CLIENT_ID: ${CLIENT_ID}
      OAUTH2_PROXY_CLIENT_SECRET: ${CLIENT_SECRET}

      OAUTH2_PROXY_SKIP_JWT_BEARER_TOKENS: true (6)
      OAUTH2_PROXY_OIDC_EXTRA_AUDIENCES: api://default (7)
      OAUTH2_PROXY_OIDC_EMAIL_CLAIM: sub (8)

      OAUTH2_PROXY_SET_XAUTHREQUEST: true (9)
      OAUTH2_PROXY_PASS_ACCESS_TOKEN: true (10)

      OAUTH2_PROXY_SESSION_STORE_TYPE: redis (11)
      OAUTH2_PROXY_REDIS_CONNECTION_URL: redis://redis

      OAUTH2_PROXY_COOKIE_REFRESH: 30m (12)
      OAUTH2_PROXY_COOKIE_NAME: SESSION (13)
      OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET} (14)

  nginx:
    image: nginx:1.21.6-alpine
    depends_on:
      - oauth2-proxy
      - web-app
    volumes:
      - ./nginx-default.conf.template:/etc/nginx/templates/default.conf.template
    ports:
      - 80:80

  redis:
    image: redis:7.0.2-alpine3.16
    volumes:
      - cache:/data

volumes:
  cache:
    driver: local
1允许所有的电子邮件地址;IdP将管理哪些用户可以访问。
2对于单一IdP的用例,跳过中间的登录页面。
3oauth2-proxy默认为https ,本例在localhost使用http
4发行者、客户ID和秘密将从.env 文件中加载。
5允许处理API客户端的JWT承载令牌。
6除了 "客户端ID "之外,配置一个额外的 "允许 "受众。
7使用JWT访问令牌的sub ,作为电子邮件地址。
8在代理的网络应用程序请求中添加用户信息头。
9可选的,将访问令牌传递给代理的web-app请求。
10使用Redis进行会话管理。
11每30分钟刷新一次cookies。
12设置会话cookie的名称为SESSION
13配置加密密钥(从.env 文件中加载)。

重新启动服务并通过浏览器访问应用程序。http://localhost/echo.再次尝试使用HTTPie。

http localhost/echo "Authorization: Bearer ${TOKEN}"

两个请求都应该显示类似的信息。

在没有任何代码修改的情况下,"echo "网络应用程序现在已经有了OIDC/OAuth 2.0的安全保障!