纲要
- SSO 与 OAuth2 基础
- 单点登录的含义:多个客户端共享同一认证状态
Spring Security作为 OAuth2 客户端,Keycloak作为授权服务器
- Keycloak 客户端配置
- 创建两个
Client:sso-client-1、sso-client-2 - 配置访问类型、重定向 URI、客户端密钥
- 创建两个
- Spring Security 客户端工程
- 两个独立但结构相同的 Spring Boot 应用(端口
8081、8082) - 依赖
spring-boot-starter-oauth2-client
- 两个独立但结构相同的 Spring Boot 应用(端口
- 核心配置
application.yml中注册client-id、client-secret、provider信息- 安全配置只需开启
oauth2Login()
- 运行效果
- 访问任意客户端受保护资源 → 重定向至 Keycloak 统一登录页
- 在一个客户端登录后,另一个客户端无需再次认证即可访问
- 技术要点
- 授权码模式,基于
Session registration与provider配置细节
- 授权码模式,基于
SSO 单点登录流程
单点登录允许用户在多个独立的应用(客户端)之间共享登录状态。一旦用户在统一认证中心完成登录,其他已注册的客户端将不再要求重复认证。本博客演示如何使用 Spring Security OAuth2 Client 连接 Keycloak 实现该模式。
Keycloak (8080) Client2 (8082) Client1 (8081) Browser Keycloak (8080) Client2 (8082) Client1 (8081) Browser #mermaid-svg-5BNAOPdEypK1Vdfs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5BNAOPdEypK1Vdfs .error-icon{fill:#552222;}#mermaid-svg-5BNAOPdEypK1Vdfs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5BNAOPdEypK1Vdfs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5BNAOPdEypK1Vdfs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5BNAOPdEypK1Vdfs .marker.cross{stroke:#333333;}#mermaid-svg-5BNAOPdEypK1Vdfs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5BNAOPdEypK1Vdfs p{margin:0;}#mermaid-svg-5BNAOPdEypK1Vdfs .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5BNAOPdEypK1Vdfs text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5BNAOPdEypK1Vdfs .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5BNAOPdEypK1Vdfs .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5BNAOPdEypK1Vdfs #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5BNAOPdEypK1Vdfs .sequenceNumber{fill:white;}#mermaid-svg-5BNAOPdEypK1Vdfs #sequencenumber{fill:#333;}#mermaid-svg-5BNAOPdEypK1Vdfs #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5BNAOPdEypK1Vdfs .messageText{fill:#333;stroke:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5BNAOPdEypK1Vdfs .labelText,#mermaid-svg-5BNAOPdEypK1Vdfs .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .loopText,#mermaid-svg-5BNAOPdEypK1Vdfs .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5BNAOPdEypK1Vdfs .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5BNAOPdEypK1Vdfs .noteText,#mermaid-svg-5BNAOPdEypK1Vdfs .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5BNAOPdEypK1Vdfs .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5BNAOPdEypK1Vdfs .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5BNAOPdEypK1Vdfs .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5BNAOPdEypK1Vdfs .actorPopupMenu{position:absolute;}#mermaid-svg-5BNAOPdEypK1Vdfs .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5BNAOPdEypK1Vdfs .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5BNAOPdEypK1Vdfs .actor-man circle,#mermaid-svg-5BNAOPdEypK1Vdfs line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5BNAOPdEypK1Vdfs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 访问受保护资源 302 重定向至 Keycloak 登录 请求登录页面 返回登录表单 提交凭证 (testuser/12345678) 302 重定向回 Client1 (携带授权码) 发送授权码 用授权码换取令牌 返回 access_token 登录成功,返回受保护资源 访问受保护资源 302 重定向至 Keycloak 已有 Session,直接颁发授权码 302 重定向回 Client2 发送授权码 换取令牌 返回 access_token 登录成功,返回资源
Keycloak 客户端创建
在使用 Spring Security 连接 Keycloak 之前,需要在授权服务器上注册客户端。本示例使用 Keycloak 的 demo 域(Realm)。
创建 Client 步骤
- 登录 Keycloak 管理控制台,进入目标 Realm。
- 创建 Client,填写以下信息:
| 字段 | Client 1 值 | Client 2 值 |
|---|---|---|
| Client ID | sso-client-1 |
sso-client-2 |
| Client Protocol | openid-connect |
openid-connect |
| Access Type | confidential |
confidential |
| Root URL | http://localhost:8081 |
http://localhost:8082 |
| Valid Redirect URIs | /login/oauth2/code/* |
/login/oauth2/code/* |
- 在
Credentials标签页中生成Client Secret,并记录对应值(例如client1-secret和client2-secret)。
注意 :Valid Redirect URIs 必须精确匹配,生产环境中避免使用通配符 *,但演示时可简化配置。
Spring Security 客户端工程
两个客户端项目结构完全一致,仅端口和 Client 信息不同。这里以 client1 为例展示完整代码。
项目结构
dir
sso-client-1/
├── pom.xml
└── src/main/
├── java/com/example/sso/
│ ├── SsoClient1Application.java
│ └── controller/
│ └── HomeController.java
└── resources/
├── application.yml
└── static/
└── index.html
Maven 依赖 (pom.xml)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>sso-client-1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>sso-client-1</name>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
应用主类
java
package com.example.sso;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SsoClient1Application {
public static void main(String[] args) {
SpringApplication.run(SsoClient1Application.class, args);
}
}
安全配置
Spring Security 5.1 之后,开启 OAuth2 客户端支持异常简洁。只需在配置中启用 oauth2Login() 即可自动完成授权码流程。
java
package com.example.sso;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.antMatchers("/", "/index.html").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(); // 核心:启用 OAuth2 登录
return http.build();
}
}
控制器
提供受保护的测试页面,用于验证登录状态。
java
package com.example.sso.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Map;
@RestController
public class HomeController {
@GetMapping("/user")
public Map<String, Object> user(@AuthenticationPrincipal OAuth2User principal) {
return Collections.singletonMap("name", principal.getAttribute("preferred_username"));
}
@GetMapping("/content")
public String content() {
return "Welcome to Client 1's protected content!";
}
}
静态首页
放置于 src/main/resources/static/index.html,提供简单的登录入口。
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Client 1</title>
</head>
<body>
<h1>Client 1</h1>
<a href="/content">登录并访问受保护内容</a>
</body>
</html>
Client 1 的 application.yml
yaml
server:
port: 8081
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: sso-client-1
client-secret: client1-secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, email
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/demo
user-name-attribute: preferred_username
配置解释:
registration中client-id和client-secret必须与 Keycloak 中创建的一致。authorization-grant-type为authorization_code,即授权码模式。redirect-uri使用模板自动拼接,无需硬编码。provider下issuer-uri指向 Keycloak 的特定 Realm,Spring Security 会自动发现端点。user-name-attribute指定返回用户信息 JSON 中的用户名字段,此处为preferred_username。
Client 2 的差异化配置
Client 2 项目结构与 Client 1 完全一致,仅需修改端口和注册信息。
yaml
server:
port: 8082
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: sso-client-2
client-secret: client2-secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, email
provider:
keycloak:
issuer-uri: http://localhost:8080/realms/demo
user-name-attribute: preferred_username
将主类、安全配置、控制器中的类名和注释相应调整,但核心逻辑不变。两个应用启动后分别监听 8081 和 8082。
运行与验证
- 确保 Keycloak 已启动并创建好对应的两个 Client。
- 分别启动
sso-client-1和sso-client-2。 - 访问
http://localhost:8081,点击登录链接,浏览器重定向至http://localhost:8080的 Keycloak 登录页。 - 输入凭据登录后,成功返回 Client 1 的受保护内容。
- 直接访问
http://localhost:8082,点击登录,浏览器短暂跳转后直接登录成功,无需再次输入密码。
单点登录生效的关键 :Keycloak 在第一次登录后与浏览器建立了 Session。当第二个客户端发起认证时,授权服务器检测到已有有效会话,直接颁发授权码,完成静默登录。
关键点总结
- 在 Spring Security 中实现 OAuth2 SSO,只需引入
spring-boot-starter-oauth2-client,并配置registration和provider。 - 安全配置中调用
.oauth2Login()即可激活整个授权码流程。 - 多个客户端共享同一个授权服务器
Session,是实现单点登录的核心机制。 - 所有配置信息与具体授权服务器解耦,符合 OAuth2 标准,因此替换 Keycloak 为其他提供商(如 Auth0、Okta)同样简单。
常见问题
Q:必须为每个客户端单独创建 Client 吗?
A:是的。OAuth2 协议要求每个客户端在授权服务器上独立注册,以确保安全隔离和回调 URI 的正确性。
Q:能否将授权服务器与资源服务器合一?
A:可以,但推荐分离部署。本例中 Keycloak 作为独立授权服务器,客户端只负责前端展示与用户资源访问。
Q:生产环境需要注意哪些?
A:重定向 URI 避免使用通配符,使用 HTTPS,妥善保管 Client Secret,并配置合理的 Token 有效期。
相关扩展
- 自定义登录页面与权限
- 结合
Spring Cloud Gateway实现统一网关下的 SSO - 多租户、多 Realm 配置
- 使用
JWT进行资源服务器间的令牌传递