我们经常收到的一个OAuth 2.0问题。"我如何在一个负载平衡的应用程序中处理OAuth?"简短的回答。关于OAuth的会话集群,并没有什么特别之处。较长的答案是,你可能仍然需要担心集群会话的管理问题。这篇文章将讨论OAuth登录与应用程序会话的关系。我们将建立一个简单的、安全的、负载平衡的应用程序来演示。
会话和OAuth 2.0应用
一个使用OAuth 2.0重定向的应用程序,即"授权码授予",通常使用一个服务器端的会话来临时存储登录过程的状态,直到完成。让我们来看看一个简化的OAuth登录流程。
-
一个用户请求登录--可以通过点击一个登录按钮,也可以在他们请求一个受保护的页面时自动登录。
-
Web App存储关于当前会话的信息--OAuth状态信息和可选的PKCE代码验证器和/或OpenId连接(OIDC)的nonce。
-
响应是一个浏览器重定向到授权服务器。
-
用户与授权服务器互动,提供证书并确认同意。
-
授权服务器发出带有代码的重定向,返回到网络应用。
-
用户授权在后端完成。
-
网络应用程序从会话中读取先前存储的OAuth数据。
-
验证用户已获得授权服务器的授权。
-
授权服务器响应,并提供OAuth访问令牌。
-
网络应用程序在会话中存储访问令牌。
-
用户被登录。
向网络应用程序提出两个单独的请求:初始登录请求和验证步骤。这两个请求都访问相同的会话信息。对于你的应用程序,这意味着一旦你开始扩展,你需要考虑会话管理。
使用JWTs的无状态
在这一点上,你可能会想,你是否可以把所有东西都塞进JWT(JSON Web Token),使这个过程无状态;消除对任何会话集群的需要。你可以,但你需要使用JWE(JSON网络加密),以确保浏览器无法访问任何敏感数据。不使用JWTs作为会话令牌的原因有很多,但这些问题不在本文的讨论范围之内。
负载均衡器粘性会话
避免会话复制或集群的另一个选择是使用 "粘性会话"(或 "会话亲和性"),但这创造了一个脆弱的环境。如果一个网络服务器发生故障或因任何原因被关闭,与该服务器相关的所有用户基本上都会被注销。粘性会话也违反了12因素的进程无状态原则。
| 不要把12因素中提到的 "进程无状态 "与上一节中的 "无状态 "混淆起来。在那里,我们特别指的是应用程序在请求之间对服务器的 "状态 "进行中继的需要,例如缓存在内存或文件中的数据。相反,应该使用一个支持服务。在会话存储的情况下,使用了Redis的例子,这正是我们在下一节要做的。 |
设置HAproxy和Redis
为了建立一个负载平衡的应用程序,我们至少需要三样东西--负载平衡器(HAproxy)、共享会话存储(Redis)和Web应用程序的多个实例(Spring Boot)。
如果你想直接跳到代码,可以看看这个GitHubrepo。
为这个项目创建一个新目录:
mkdir oauth-sessions
cd oauth-sessions
配置HAproxy
HAproxy用于在多个backend 应用程序之间分配请求,并创建一个配置文件,haproxy.cfg ,该文件将为两个不同的Web应用程序提供服务(一个在端口8081 ,另一个在8082 ):
global
daemon
maxconn 2000
# send request logs to stdout, to make debugging easier
log stdout format raw local0
defaults
mode http
log global
option httplog
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend http-in
bind *:8080
default_backend webapps
backend webapps
balance roundrobin
# `host.docker.internal` refers to host that is running Docker Desktop
# On Linux add `--add-host=host.docker.internal:host-gateway` to `docker run` to
# mimic the functionality
server webapp1 host.docker.internal:8081
server webapp2 host.docker.internal:8082
| 确保有一个尾部的换行,否则你可能在启动HAproxy时遇到麻烦。 |
用Docker启动HAproxy和Redis
我将在我的笔记本电脑上直接运行示例的Web应用程序,但HAproxy和Redis都可以作为Docker容器运行。创建一个docker-compose.yml 文件:
version: '3.8'
services:
haproxy:
image: docker.io/haproxy:2.4-alpine
volumes:
- ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg
extra_hosts:
# Docker Desktop uses `host.docker.internal` for the host,
# mimic this for linux installs, requires Docker 20.10+
- host.docker.internal:host-gateway
ports:
- 8080:8080
redis:
# Starts Redis without persistence
image: docker.io/redis:6.2.5-alpine
ports:
- 6379:6379
通过运行来启动HAproxy和Redis:
docker compose up
你可以通过按Ctrl+C或在同一目录下运行docker compose down 命令来停止该进程。 |
构建一个安全的Spring Boot应用程序
现在,系统的依赖性已经出来了,让我们继续构建一个Spring Boot应用程序。
通过访问start.spring.io并选择Web和Okta依赖项或运行以下命令,创建一个新的Spring Boot应用程序:
https start.spring.io/starter.tgz \
bootVersion==2.5.4 \
dependencies==web,okta \
groupId==com.example \
artifactId==webapp \
name=="Web Application" \
description=="Demo Web Application" \
packageName==com.example \
javaVersion==11 \
| tar -xzvf -
为了直观地显示哪个服务器处理了请求,创建一个REST控制器,在src/main/java/com/example/Endpoints.java ,显示服务器端口:
package com.example;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class Endpoints {
@GetMapping("/")
String serverInfo(@Value("${server.port}") int port) {
return "Hello, your server port is: " + port;
}
}
启动Spring Boot应用程序
在上一节中,HAproxy被绑定到端口8080 ,这也是Spring Boot的默认端口。使用8081 ,在端口上启动应用程序:
SERVER_PORT=8081 ./mvnw spring-boot:run
在这一点上,Spring Boot应用程序没有被配置为使用Redis或OAuth,但你仍然可以通过从控制台输出抓取自动生成的密码来测试服务器。它看起来会是这样的:
Using generated security password: 4302a714-580b-4d01-91d9-5d9597ee1bb5
复制密码并向Spring Boot应用程序发出请求:
http :8081/ --auth user:<your-password>
你会看到一个包含服务器端口的响应:
Hello, your server port is: 8081
很好,这意味着Spring Boot应用已经启动并运行了现在确保你可以通过负载平衡器访问服务器,端口为8080:
http :8080/ --auth user:<your-password>
你应该看到相同的响应;如果你看到一个503 Service Unavailable ,再试一次请求。
在这篇文章中使用的HAproxy配置没有启用健康检查,所以它将在端口8081 和8082 之间交替请求;这是故意的,以简化配置。如果你想在你的应用程序中添加健康检查和其他监控,请看看Spring Actuator。 |
使用Ctrl+C 停止Spring Boot服务器。现在是时候用OAuth 2.0来保护应用程序了。
用OAuth 2.0保护Spring Boot
在你开始之前,你需要一个免费的Okta开发者账户。安装Okta CLI并运行okta register ,以注册一个新账户。如果你已经有一个账户,运行okta login 。然后,运行okta apps create 。选择默认的应用程序名称,或者根据你的需要进行更改。 选择Web,然后按Enter键。
选择Okta Spring Boot Starter。 接受为您提供的默认Redirect URI值。也就是说,登录重定向为http://localhost:8080/login/oauth2/code/okta ,注销重定向为http://localhost:8080 。
Okta CLI是做什么的?
Okta CLI将在您的Okta机构中创建一个OIDC网络应用。它将添加您指定的重定向URI,并授予Everyone组的访问权。当它完成后,您会看到如下输出:
Okta application configuration has been written to:
/path/to/app/src/main/resources/application.properties
打开src/main/resources/application.properties ,查看你的应用程序的发行者和证书:
okta.oauth2.issuer=https://dev-133337.okta.com/oauth2/default
okta.oauth2.client-id=0oab8eb55Kb9jdMIr5d6
okta.oauth2.client-secret=NEVER-SHOW-SECRETS
注意:你也可以使用Okta管理控制台来创建你的应用程序。更多信息请参见创建Spring Boot应用程序。
现在,应用程序已被配置为使用OAuth 2.0,启动两个不同的实例(打开两个不同的终端窗口):
SERVER_PORT=8081 ./mvnw spring-boot:run
而第二个则在端口8082:
SERVER_PORT=8082 ./mvnw spring-boot:run
通过负载均衡器访问应用程序会产生奇怪的结果;打开一个私人/隐身窗口到http://localhost:8080 ,并尝试登录。你会被重定向到Okta,在那里你可以输入你的账户凭证。然而,在按下登录键后,你会看到一个错误页面。
回想一下本文开头的顺序图,看看你是否能发现问题所在。最初的登录请求(第一步)发生在一个实例上,而最后的请求发生在另一个实例上(第六步)。更新应用程序以使用共享会话存储将解决这个问题。
让我们这么做吧!
与Redis共享会话
如果你一直在关注,你已经有一个Redis服务器在运行;现在我们要配置Spring Boot应用程序来利用它。幸运的是,Spring Session使这一过程不费吹灰之力。
打开pom.xml ,在<dependencies> 块中添加以下内容:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
默认情况下,Spring Session将Redis配置为连接到localhost ,端口为6379 ,没有密码。看看Spring Session文档中的各种配置选项。 |
重新启动Spring Boot应用程序。(记住有一个在端口8081 ,另一个在8082 )。
再次打开浏览器,尝试访问http://localhost:8080/ ;这一次,你将能够顺利登录
刷新几次浏览器,你会看到在两个端口之间交替出现的响应:
Hello, your server port is: 8081
和:
Hello, your server port is: 8082
很简单,只需要几个依赖项就可以配置共享会话存储了!如果Redis不适合你,Spring Session也支持数据库、Hazelcast、MongoDB和Apache Geode。
了解更多OAuth 2.0和会话管理
这篇文章展示了如何为一个使用OAuth 2.0的简单负载平衡应用程序管理会话。我们的例子集中在应用程序的Spring Boot部分,但它还远未达到可用于生产的程度。首先需要解决其他一些问题:
-
单一的HAproxy和Redis实例是单一的故障点
-
Redis数据没有持久化
-
Redis连接不安全
-
应该为HAproxy和Spring Boot应用程序启用TLS。