更新一个应用程序以使用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)功能的服务器都可以使用,例如Auth0或Keycloak,但这是一个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容器。 |
| 4 | OIDC客户信息(发行者、客户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 | 不要把这个路由暴露给外部客户。 |
| 2 | Nginx向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的用例,跳过中间的登录页面。 |
| 3 | oauth2-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的安全保障!