Spring Security OAuth2 实战——授权服务器搭建与客户端接入完整流程
Spring Security OAuth2 实战——授权服务器搭建与客户端接入完整流程
适读人群:需要实现单点登录或第三方授权的Java后端开发者 | 阅读时长:约20分钟 | 核心价值:完整理解OAuth2.0授权流程,能独立搭建Authorization Server并完成客户端接入
那次被产品经理逼着做SSO的经历
2022年初,产品经理给我提了一个需求:公司有3个系统(OA、CRM、ERP),用户在A系统登录后,访问B、C系统不需要再登录,实现"单点登录(SSO)"。
我当时的第一反应是:哦,Session共享嘛。把三个系统的Session存到Redis,就行了。
产品给我泼了一盆冷水:这三个系统技术栈不一样,A是Java,B是Python,C是PHP。而且以后可能会接入外部合作方的系统,他们的系统我们控制不了。
我沉默了片刻,说:那这是OAuth2的场景。
然后花了两周时间,搭建了一套Authorization Server,把这三个系统都接进去了。今天把整个过程还原出来。
OAuth2.0 核心概念
四个角色
| 角色 | 说明 |
|---|---|
| Resource Owner | 资源所有者,即用户 |
| Client | 想要访问资源的应用(OA、CRM等) |
| Authorization Server | 授权服务器,负责认证用户并颁发Token |
| Resource Server | 资源服务器,提供受保护的资源API |
四种授权流程
1. Authorization Code(授权码模式) — 最安全,Web应用首选
用户 → 客户端:点击"用XXX登录"
客户端 → 授权服务器:重定向,带client_id、redirect_uri、scope、state
授权服务器 → 用户:展示登录页+授权确认页
用户 → 授权服务器:输入账号密码,确认授权
授权服务器 → 客户端:重定向到redirect_uri,带授权码code
客户端 → 授权服务器:用code + client_secret 换取access_token(后端到后端)
授权服务器 → 客户端:返回access_token、refresh_token
客户端 → 资源服务器:用access_token访问资源PKCE(Proof Key for Code Exchange)是授权码模式的增强,适用于无法安全保存client_secret的场景(SPA、移动App)。
2. Client Credentials(客户端凭证模式) — 服务间调用,无用户参与
3. Implicit(隐式模式) — 已废弃,不再推荐
4. Device Authorization(设备授权模式) — 智能电视、命令行工具
搭建 Authorization Server(Spring Authorization Server)
Spring Authorization Server是Spring官方的OAuth2授权服务器实现(Spring Security的子项目)。
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>1.2.3</version>
</dependency>import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
/**
* Authorization Server 完整配置
* Spring Authorization Server 1.2.x
*/
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
/**
* 授权服务器的SecurityFilterChain(端口9000)
* 处理:/oauth2/authorize、/oauth2/token、/oauth2/jwks等端点
*/
@Bean
@Order(1) // 优先级高于下面的defaultSecurityFilterChain
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
// 配置OIDC(OpenID Connect)
authorizationServerConfigurer.oidc(oidc -> oidc
.userInfoEndpoint(userInfo -> userInfo
.userInfoMapper(context -> {
// 自定义userInfo端点返回的用户信息
OidcUserInfoAuthenticationContext authContext =
(OidcUserInfoAuthenticationContext) context;
OidcUserInfo.Builder builder = OidcUserInfo.builder();
String username = authContext.getAuthorization().getPrincipalName();
builder.subject(username)
.claim("username", username)
.claim("email", getUserEmail(username));
return builder.build();
})
)
);
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
http
.securityMatcher(endpointsMatcher)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
// 未认证时重定向到登录页
http.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
return http.build();
}
/**
* 应用本身的安全配置(登录页等)
*/
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/assets/**", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.permitAll()
);
return http.build();
}
/**
* 注册客户端(可以从数据库动态加载)
*/
@Bean
public RegisteredClientRepository registeredClientRepository() {
// OA 系统客户端
RegisteredClient oaClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("oa-system")
.clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("oa-secret-2024"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://oa.company.com/login/oauth2/code/company-sso")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("read:orders")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(false) // 内部系统不需要授权确认页
.requireProofKey(false)
.build()
)
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofDays(7))
.reuseRefreshTokens(false) // 每次刷新都换新的refresh token
.build()
)
.build();
// 服务间调用客户端(Client Credentials模式)
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("internal-service")
.clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("service-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("internal:api")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(5)) // 服务间token更短
.build()
)
.build();
return new InMemoryRegisteredClientRepository(oaClient, serviceClient);
// 生产环境:JdbcRegisteredClientRepository
}
/**
* JWT签名密钥(用于签名发出的Token)
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsaKey();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
private static RSAKey generateRsaKey() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey(keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
} catch (Exception e) {
throw new RuntimeException("生成RSA密钥失败", e);
}
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://auth.company.com") // 授权服务器地址
.build();
}
/**
* 自定义Token中的额外Claims
*/
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return context -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
Authentication principal = context.getPrincipal();
String username = principal.getName();
// 添加自定义Claims
context.getClaims()
.claim("username", username)
.claim("tenant_id", getTenantId(username))
.claim("dept_code", getDeptCode(username));
}
};
}
private String getUserEmail(String username) { return username + "@company.com"; }
private String getTenantId(String username) { return "tenant-001"; }
private String getDeptCode(String username) { return "TECH"; }
}客户端接入(Resource Server配置)
/**
* OA系统作为 OAuth2 Resource Server 的配置
* 验证来自Authorization Server的JWT Token
*/
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin:all")
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_read:orders")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwkSetUri("https://auth.company.com/oauth2/jwks") // 从授权服务器获取公钥
.jwtAuthenticationConverter(jwtAuthenticationConverter()) // 自定义转换器
)
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"error\":\"未授权,请先登录\"}");
})
);
return http.build();
}
/**
* 将JWT中的roles转换为Spring Security的权限
*/
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix("SCOPE_"); // 与JWT中的scope对应
converter.setAuthoritiesClaimName("scope");
JwtAuthenticationConverter jwtConverter = new JwtAuthenticationConverter();
jwtConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
// 合并scope权限和roles权限
List<GrantedAuthority> authorities = new ArrayList<>();
// scope权限
Collection<GrantedAuthority> scopeAuthorities = converter.convert(jwt);
if (scopeAuthorities != null) authorities.addAll(scopeAuthorities);
// 自定义roles(如果Token里有)
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
}
return authorities;
});
return jwtConverter;
}
}三个踩坑实录
坑一:redirect_uri 严格匹配导致客户端接入失败
现象: 客户端访问授权端点,返回"redirect_uri_mismatch"错误,明明配置了URI但就是不通过。
原因: OAuth2对redirect_uri做精确匹配,一个字符都不能差。常见问题:
https://oa.company.com/callbackvshttps://oa.company.com/callback/(末尾斜线不同)- 生产用HTTPS,注册时填的是HTTP
- 客户端请求时动态拼接了query参数
// 注册时必须与客户端实际使用的URL完全一致
.redirectUri("https://oa.company.com/login/oauth2/code/company-sso")
// 不能是 "https://oa.company.com/login/oauth2/code/company-sso/"解法: 一字不差地检查两边的URI,建议直接复制粘贴而不是手敲。
坑二:Token中的信息过时(权限变更不即时生效)
现象: 运营人员刚被撤销了管理员权限,但还能用现有的Access Token继续操作管理功能,直到Token过期。
原因: JWT是自包含的,Token颁发时权限信息就固化在Token里了,服务端不会每次都查数据库。
解法:
- 缩短Access Token有效期(15-30分钟),降低影响窗口
- 在Resource Server中加入"Token吊销检查":调用Authorization Server的Introspection端点(会增加延迟)
- 对于高敏感操作,不依赖Token权限,每次都实时查数据库
// 方案3:高敏感操作实时验证权限
@PreAuthorize("@permissionService.hasPermission(authentication, 'admin:delete')")
public void deleteUser(Long userId) { ... }坑三:前端SPA客户端不能保存client_secret
现象: SPA应用(React/Vue)想接入OAuth2,但如果把client_secret放在前端JS里,任何人打开开发者工具都能看到。
原因: client_secret是机密,只能在服务端保存,SPA没有安全的服务端存储。
解法: 使用PKCE(Proof Key for Code Exchange):
// 前端:生成PKCE code_verifier 和 code_challenge
const codeVerifier = generateRandomString(128);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));
// 授权请求中带上 code_challenge
const authUrl = `https://auth.company.com/oauth2/authorize?
client_id=spa-client
&code_challenge=${codeChallenge}
&code_challenge_method=S256
&...`;
// 换Token时带上 code_verifier(无需client_secret)
const tokenResponse = await fetch('/oauth2/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
code_verifier: codeVerifier, // 代替client_secret
// 不需要 client_secret
})
});服务端注册PKCE客户端:
.clientSettings(ClientSettings.builder()
.requireProofKey(true) // 强制要求PKCE
.build()
)小结
OAuth2.0的本质是受控的授权委托:资源所有者(用户)授权客户端(应用)以有限的方式访问自己的资源,而不需要把密码交给客户端。
搭建Authorization Server的核心工作:
- 注册合法客户端,严格控制redirect_uri
- 配置Token签名密钥(生产用RSA非对称密钥)
- 按需添加额外Claims(租户、部门等业务信息)
- 实现客户端接入(Resource Server验证JWT)
