一、方案选型
表格
| 方案 | 协议 | 适用场景 | 复杂度 |
|---|---|---|---|
| Apereo CAS | CAS 协议 | 传统企业内网多系统 | 高(需独立部署 CAS Server) |
| Spring Authorization Server | OAuth2 + OIDC | 微服务 / 前后端分离 / 云原生 | 中 |
| 自研 JWT 共享认证 | 自定义 | 简单内网系统 | 低 |
| Keycloak | OAuth2/OIDC/SAML | 中大型企业,需要身份管理 | 中(开箱即用) |
推荐方案:Spring Authorization Server + Spring Security OAuth2 Client + JWT
理由:Spring 官方维护,与 Spring Boot/Cloud 生态无缝集成,支持 OAuth2 + OIDC 标准协议,可同时覆盖 Web 系统、移动端、API 网关等多种接入方式。
二、整体架构
plaintext
┌──────────┐ 1.未登录跳转 ┌──────────────────────┐
│ 浏览器 │ ───────────────────> │ 业务系统 A (Client) │
│ │ <─────────────────── │ │
└──────────┘ 3.带code回调 └──────────┬───────────┘
│ │
│ 2.重定向到认证中心 │ 4. code换token
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ SSO 认证中心 │ │ Redis (Token存储) │
│ (Authorization │◄────────►│ Session共享 │
│ Server) │ └──────────────────────┘
│ - 用户认证 │
│ - Token签发 │ ┌──────────────────────┐
│ - 客户端管理 │◄────────►│ MySQL (用户/权限) │
└──────────────────────┘ └──────────────────────┘
▲
│ 5.校验Token
┌──────────┴───────────┐
│ 业务系统 B (Client) │
└──────────────────────┘
核心流程(Authorization Code 模式):
- 用户访问业务系统 A,未登录 → 重定向到 SSO 认证中心
- 用户在认证中心登录(首次),认证中心签发 SSO Session
- 认证中心重定向回业务系统 A,附带 authorization code
- 业务系统 A 用 code 换取 access_token + refresh_token
- 用户访问业务系统 B → 重定向到认证中心 → 检测到 SSO Session 已存在 → 直接回调签发 code → 无感登录
三、认证中心实现(Authorization Server)
3.1 依赖配置(pom.xml)
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Authorization Server -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
3.2 安全配置
java
运行
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // 启用 OIDC
http.exceptionHandling(e -> e
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(form -> form.loginPage("/login").permitAll())
.csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**"));
return http.build();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepository) {
return username -> {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getEnabled(),
true, true, true,
AuthorityUtils.createAuthorityList(
user.getRoles().stream()
.map(r -> "ROLE_" + r.getCode())
.toArray(String[]::new)
)
);
};
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
3.3 客户端与 Token 配置
java
运行
@Configuration
public class AuthorizationServerConfig {
@Bean
public RegisteredClientRepository registeredClientRepository(
JdbcTemplate jdbcTemplate) {
// 生产环境从数据库加载客户端配置
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
@Bean
public OAuth2AuthorizationService authorizationService(
JdbcTemplate jdbcTemplate,
RegisteredClientRepository clientRepository) {
// 使用 Redis 存储 Token,支持分布式部署
return new RedisOAuth2AuthorizationService(redisTemplate, clientRepository);
}
@Bean
public OAuth2AuthorizationConsentService consentService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository clientRepository) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clientRepository);
}
@Bean
public JWKSource<SecurityContext> jwkSource() throws Exception {
RSAKey rsaKey = JwksGenerator.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://sso.example.com")
.authorizationEndpoint("/oauth2/authorize")
.tokenEndpoint("/oauth2/token")
.jwkSetEndpoint("/oauth2/jwks")
.oidcUserInfoEndpoint("/userinfo")
.build();
}
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
Authentication principal = context.getPrincipal();
Set<String> authorities = principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
context.getClaims().claim("authorities", authorities);
context.getClaims().claim("user_id",
((UserDetails) principal.getPrincipal()).getUsername());
}
};
}
}
3.4 RSA 密钥对生成器
java
运行
public final class JwksGenerator {
public static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
private static KeyPair generateRsaKey() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
3.5 登录控制器
java
运行
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
3.6 数据库初始化脚本
sql
-- 客户端注册信息表(Spring Authorization Server 标准表)
CREATE TABLE oauth2_registered_client (
id varchar(100) NOT NULL,
client_id varchar(100) NOT NULL,
client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret varchar(200) DEFAULT NULL,
client_secret_expires_at timestamp DEFAULT NULL,
client_name varchar(200) NOT NULL,
client_authentication_methods varchar(1000) NOT NULL,
authorization_grant_types varchar(1000) NOT NULL,
redirect_uris varchar(1000) DEFAULT NULL,
post_logout_redirect_uris varchar(1000) DEFAULT NULL,
scopes varchar(1000) NOT NULL,
client_settings varchar(2000) NOT NULL,
token_settings varchar(2000) NOT NULL,
PRIMARY KEY (id)
);
-- 用户表
CREATE TABLE sys_user (
id bigint PRIMARY KEY AUTO_INCREMENT,
username varchar(50) NOT NULL UNIQUE,
password varchar(200) NOT NULL,
real_name varchar(50),
email varchar(100),
phone varchar(20),
enabled tinyint DEFAULT 1,
created_at timestamp DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 角色表
CREATE TABLE sys_role (
id bigint PRIMARY KEY AUTO_INCREMENT,
code varchar(50) NOT NULL UNIQUE,
name varchar(50) NOT NULL
);
-- 用户角色关联
CREATE TABLE sys_user_role (
user_id bigint NOT NULL,
role_id bigint NOT NULL,
PRIMARY KEY (user_id, role_id)
);
-- 注册一个业务系统客户端(示例)
INSERT INTO oauth2_registered_client
(id, client_id, client_secret, client_name,
client_authentication_methods, authorization_grant_types,
redirect_uris, scopes, client_settings, token_settings)
VALUES (
'client-app-a',
'app-a',
'{bcrypt}$2a$10$...', -- 加密后的密钥
'业务系统A',
'client_secret_basic',
'authorization_code,refresh_token',
'http://app-a.example.com/login/oauth2/code/sso',
'openid,profile,roles',
'{"@class":"java.util.Collections$UnmodifiableMap","settings.client.require-proof-key":false,"settings.client.require-authorization-consent":false}',
'{"@class":"java.util.Collections$UnmodifiableMap","settings.token.authorization-code-time-to-live":300,"settings.token.access-token-time-to-live":3600,"settings.token.access-token-format":"self-contained","settings.token.refresh-token-time-to-live":2592000,"settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":"RS256"}'
);
四、业务系统客户端实现
4.1 依赖配置
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
4.2 application.yml
yaml
spring:
security:
oauth2:
client:
registration:
sso:
client-id: app-a
client-secret: your-client-secret
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
scope: openid, profile, roles
provider:
sso:
issuer-uri: https://sso.example.com
user-name-attribute: sub
resourceserver:
jwt:
issuer-uri: https://sso.example.com
4.3 客户端安全配置
java
运行
@Configuration
@EnableWebSecurity
public class ClientSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login**").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/sso")
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(userAuthoritiesMapper())))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.logout(logout -> logout
.logoutSuccessUrl("https://sso.example.com/connect/logout?post_logout_redirect_uri=http://app-a.example.com&client_id=app-a"));
return http.build();
}
@Bean
public GrantedAuthoritiesMapper userAuthoritiesMapper() {
return authorities -> {
Set<GrantedAuthority> mapped = new HashSet<>();
authorities.forEach(auth -> {
if (auth instanceof OidcUserAuthority oidcAuth) {
OidcUserInfo userInfo = oidcAuth.getUserInfo();
List<String> roles = userInfo.getClaimAsStringList("authorities");
if (roles != null) {
roles.forEach(role -> mapped.add(new SimpleGrantedAuthority(role)));
}
}
mapped.add(auth);
});
return mapped;
};
}
}
4.4 获取当前登录用户信息
java
运行
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/me")
public Map<String, Object> currentUser(
@AuthenticationPrincipal OidcUser principal) {
Map<String, Object> result = new HashMap<>();
result.put("username", principal.getClaim("user_id"));
result.put("name", principal.getFullName());
result.put("email", principal.getEmail());
result.put("authorities", principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList());
return result;
}
}
五、网关层统一鉴权(微服务场景)
如果是 Spring Cloud 微服务架构,在 Gateway 层做统一 Token 校验:
java
运行
@Configuration
public class GatewaySecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
http.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchange -> exchange
.pathMatchers("/auth/**", "/public/**").permitAll()
.anyExchange().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
yaml
# Gateway application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://sso.example.com
cloud:
gateway:
routes:
- id: service-a
uri: lb://service-a
predicates:
- Path=/api/a/**
filters:
- StripPrefix=2
六、单点登出(SLO)
6.1 认证中心登出端点
java
运行
@RestController
public class LogoutController {
@GetMapping("/connect/logout")
public void logout(HttpServletRequest request,
HttpServletResponse response,
@RequestParam("post_logout_redirect_uri") String redirectUri)
throws IOException {
// 清除认证中心 Session
SecurityContextLogoutHandler handler = new SecurityContextLogoutHandler();
handler.logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
// 通知所有已注册客户端登出(前端通过 iframe 轮询或后端回调)
response.sendRedirect(redirectUri);
}
}
6.2 前端单点登出方案(iframe 通知)
在认证中心登出页面嵌入所有客户端的登出端点:
html
预览
<!-- 认证中心登出页面 -->
<iframe src="http://app-a.example.com/logout" style="display:none"></iframe>
<iframe src="http://app-b.example.com/logout" style="display:none"></iframe>
<script>
setTimeout(() => { window.location.href = '/login'; }, 1000);
</script>
七、部署步骤
7.1 环境准备
bash
# 1. 安装 MySQL 8.0 + Redis 6+
# 2. 创建数据库
mysql -u root -p -e "CREATE DATABASE sso_auth DEFAULT CHARACTER SET utf8mb4;"
# 3. 执行初始化脚本
mysql -u root -p sso_auth < schema.sql
7.2 认证中心部署
bash
# 打包
mvn clean package -DskipTests
# 运行(生产环境使用 Docker/K8s)
java -jar sso-auth-server.jar \
--spring.datasource.url=jdbc:mysql://mysql:3306/sso_auth \
--spring.datasource.username=root \
--spring.datasource.password=xxx \
--spring.redis.host=redis \
--server.port=9000
7.3 Nginx 反向代理配置
nginx
upstream sso_server {
server 127.0.0.1:9000;
}
server {
listen 443 ssl;
server_name sso.example.com;
ssl_certificate /etc/nginx/ssl/sso.crt;
ssl_certificate_key /etc/nginx/ssl/sso.key;
location / {
proxy_pass http://sso_server;
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-Proto $scheme;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=Lax";
}
}
7.4 Docker Compose 一键部署
yaml
version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_DATABASE: sso_auth
volumes:
- mysql_data:/var/lib/mysql
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
ports:
- "3306:3306"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
sso-server:
build: ./sso-auth-server
depends_on:
- mysql
- redis
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/sso_auth
SPRING_REDIS_HOST: redis
ports:
- "9000:9000"
volumes:
mysql_data:
八、安全加固清单
表格
| 项目 | 措施 |
|---|---|
| 传输安全 | 全站 HTTPS,HSTS 启用 |
| Token 安全 | access_token 有效期 ≤ 2h,refresh_token 支持轮换 |
| 密码存储 | BCrypt 加密,强度 ≥ 10 |
| 防重放 | authorization_code 一次性使用,有效期 ≤ 5min |
| CSRF | 表单登录启用 CSRF Token |
| 密钥管理 | RSA 私钥不硬编码,使用 KMS/Vault 管理 |
| 审计日志 | 记录登录 / 登出 / Token 签发 / 失败尝试 |
| 限流 | 登录接口限流(如 5 次 / 分钟 / IP) |
| Cookie | HttpOnly + Secure + SameSite=Lax |
| 会话固定 | 登录后重新生成 Session ID |
九、关键注意事项
- 时钟同步:所有参与 SSO 的服务器必须 NTP 时间同步,否则 JWT 过期校验会异常
- 跨域配置:认证中心与各业务系统域名不同时,需正确配置 CORS
- Session 共享:认证中心集群部署时,Session 必须存 Redis 或使用无状态 JWT
- 客户端密钥轮换:定期更换 client_secret,支持双密钥过渡
- 兼容老系统:不支持 OAuth2 的老系统可通过 CAS 协议适配层或反向代理注入 Header 实现接入