不透明令牌支持
概述
JWT 是一种以广泛接受的 JSON 格式安全传输敏感信息的方法。包含的信息可能是关于用户的,也可能是关于令牌本身的,例如它的到期时间和发行者。 但是将令牌信息打包放入令牌本身也有其不足之处。为了包含所有必要的声明以及保护这些声明所需的签名结构,令牌尺寸会变得非常大。而且, 如果受保护资源完全依赖令牌本身所包含的信息,则一旦将有效的令牌生成并发布,想要撤回会非常困难。
OAuth2 令牌内省协议定义了一种机制,让受保护资源能够主动向授权服务器查询令牌状态。本文我们不在使用JWT结构化令牌,而是使用不透明令牌做为访问令牌。 顾名思义,不透明令牌就其携带的信息而言是不透明的。令牌只是一个标识符,指向存储在授权服务器上的信息;它通过授权服务器的内省得到验证。
授权服务器
本节中,我们将学习使用Spring Authorization Server设置 OAuth 2.0 授权服务器,并且我们将使用不透明令牌,和以往文章中授权服务器生成JWT令牌配置相比,配置过程中仅有微小的变动。
AuthorizationServerConfig.java
java
/*
* Copyright [yyyy] [name of copyright owner]
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.pigcloud.pigx.auth.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import com.pigcloud.pigx.auth.authentication.DeviceClientAuthenticationConverter;
import com.pigcloud.pigx.auth.authentication.DeviceClientAuthenticationProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.UUID;
/**
* 授权服务器配置
*
* @author heyuq
*/
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
/**
* 自定义同意授权页面。
*/
private static final String CUSTOM_CONSENT_PAGE_URI = "/oauth2/consent";
/**
* 授权服务器端点配置
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(
HttpSecurity http, RegisteredClientRepository registeredClientRepository,
AuthorizationServerSettings authorizationServerSettings) throws Exception {
// 应用OAuth2授权服务器的默认安全配置。
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
DeviceClientAuthenticationConverter deviceClientAuthenticationConverter =
new DeviceClientAuthenticationConverter(
authorizationServerSettings.getDeviceAuthorizationEndpoint());
DeviceClientAuthenticationProvider deviceClientAuthenticationProvider =
new DeviceClientAuthenticationProvider(registeredClientRepository);
// 配置OAuth2授权服务器的安全过滤链,启用OpenID Connect 1.0。
// OpenID Connect是OAuth 2.0的一个扩展,用于添加用户身份验证。
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
// 设置设备码用户验证url(自定义用户验证页)
.deviceAuthorizationEndpoint(deviceAuthorizationEndpoint ->
deviceAuthorizationEndpoint.verificationUri("/activate")
)
// 设置验证设备码用户确认页面
.deviceVerificationEndpoint(deviceVerificationEndpoint ->
deviceVerificationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.clientAuthentication(clientAuthentication ->
// 客户端认证添加设备码的converter和provider
clientAuthentication
.authenticationConverter(deviceClientAuthenticationConverter)
.authenticationProvider(deviceClientAuthenticationProvider)
)
// 设置自定义用户确认授权页
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint
.consentPage(CUSTOM_CONSENT_PAGE_URI)
)
.oidc(Customizer.withDefaults());
// @formatter:off
http
// 配置异常处理,当从授权端点未认证时,重定向到登录页面。
// 这里定义了一个登录URL认证入口点,并指定它只适用于TEXT_HTML媒体类型。
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
)
// 配置OAuth2资源服务器,接受用户信息和/或客户端注册的访问令牌。
// 这里使用JWT(JSON Web Tokens)作为访问令牌的格式。
.oauth2ResourceServer((resourceServer) -> resourceServer
// .jwt(Customizer.withDefaults())
.opaqueToken(Customizer.withDefaults())
);
// @formatter:on
// 构建并返回配置好的SecurityFilterChain对象。
return http.build();
}
/**
* 默认安全过滤器配置
*
* @param http spring security核心配置类
* @return 过滤器链
* @throws Exception 抛出
*/
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
throws Exception {
// @formatter:off
http
// 配置默认的HTTP请求授权规则,要求所有请求都必须经过身份验证。
.authorizeHttpRequests((authorize) -> authorize
// 放行静态资源
.requestMatchers("favicon.svg", "/assets/**", "/webjars/**", "/error").permitAll()
.anyRequest().authenticated()
)
// 配置表单登录,使用默认设置。
// 这处理从授权服务器过滤链重定向到登录页面的情况。
// .formLogin(Customizer.withDefaults())
.formLogin(formLogin ->
formLogin
.loginPage("/login")
.permitAll()
);
// @formatter:on
return http.build();
}
/**
* 先暂时配置一个基于内存的用户,框架在用户认证时会默认调用
* {@link UserDetailsService#loadUserByUsername(String)} 方法根据
* 账号查询用户信息,一般是重写该方法实现自己的逻辑
*
* @return UserDetailsService
*/
@Bean
public UserDetailsService userDetailsService() {
// 创建一个具有默认密码编码器的用户详情对象。
// 这里定义了一个用户名为"admin",密码为"password",角色为"ADMIN"的用户。
UserDetails userDetails = User.withUsername("admin")
.password("{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG")
.roles("ADMIN")
.authorities("message_read", "message_write")
.build();
// 返回一个基于内存的用户详情服务,它仅包含上面定义的用户。
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
/**
* 配置客户端Repository
*
* @param jdbcTemplate db 数据源信息
* @return 基于数据库的repository
*/
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
// 创建一个RegisteredClient对象,并为其分配一个唯一的ID,该ID是基于UUID生成的。
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
// 设置该客户端的ID为"messaging-client"。
.clientId("messaging-client")
// 设置客户端的秘密为"{noop}secret",这里的"{noop}"表示不对秘密进行加密处理。
.clientSecret("{noop}secret")
// 设置客户端认证方法为基于客户端秘密的基本认证。
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
// 为该客户端配置授权类型,首先配置为授权码模式。
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// 添加刷新令牌授权类型,允许客户端使用刷新令牌来获取新的访问令牌。
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 添加客户端凭证授权类型,允许客户端使用其凭证直接获取令牌。
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
// 设置授权码流程的重定向URI,当授权流程完成后,用户将被重定向到这个URI。
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
// 设置另一个重定向URI,可能用于其他授权流程。
.redirectUri("http://127.0.0.1:8080/authorized")
// 设置一个仅用于测试的重定向URI,用于接收授权码。
.redirectUri("https://www.pigx.cn")
// 设置用户注销后的重定向URI。
.postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
// 添加OpenID Connect的范围,允许客户端访问用户的身份信息。
.scope(OidcScopes.OPENID)
// 添加Profile范围,允许客户端访问用户的个人资料信息。
.scope(OidcScopes.PROFILE)
// 添加自定义范围"message.read",允许客户端读取消息。
.scope("message.read")
// 添加自定义范围"message.write",允许客户端写入消息。
.scope("message.write")
// 设置客户端设置,要求每次都需要用户授权同意。
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
// 配置令牌设置,设置访问令牌的有效时间为30分钟。
.tokenSettings(TokenSettings.builder()
// .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
.accessTokenTimeToLive(Duration.ofMinutes(30))
.build())
// 构建并返回配置好的RegisteredClient对象。
.build();
// 创建另一个RegisteredClient对象,名为deviceClient,用于设备授权
RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("device-messaging-client")
// 公共客户端
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
// 设备码授权
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
// 自定scope
.scope("message.read")
.scope("message.write")
.build();
// 创建一个JdbcRegisteredClientRepository实例,用于将已注册的客户端保存到数据库中
JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
// 初始化客户端
if (registeredClientRepository.findByClientId(registeredClient.getClientId()) == null) {
registeredClientRepository.save(registeredClient);
}
// 创建另一个RegisteredClient对象,名为deviceClient,用于设备授权
if (registeredClientRepository.findByClientId(deviceClient.getClientId()) == null) {
registeredClientRepository.save(deviceClient);
}
return registeredClientRepository;
}
/**
* 配置基于db的oauth2的授权管理服务
*
* @param jdbcTemplate db数据源信息
* @param registeredClientRepository 上边注入的客户端repository
* @return JdbcOAuth2AuthorizationService
*/
@Bean
public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {
// 基于db的oauth2认证服务,还有一个基于内存的服务InMemoryOAuth2AuthorizationService
return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);
}
/**
* 存储新授权同意和查询现有授权同意的核心组件。它主要由实现 OAuth2 授权请求流(例如 authorization_code 授权)的组件使用
*
* @param jdbcTemplate db数据源信息
* @param registeredClientRepository 客户端repository
* @return JdbcOAuth2AuthorizationConsentService
*/
@Bean
public JdbcOAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository registeredClientRepository) {
// Will be used by the ConsentController
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);
}
/**
* 配置jwk源,使用非对称加密,公开用于检索匹配指定选择器的JWK的方法
*
* @return JWKSource
*/
@Bean
public JWKSource<SecurityContext> jwkSource() {
// 生成RSA密钥对。
KeyPair keyPair = generateRsaKey();
// 从密钥对中获取公钥。
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
// 从密钥对中获取私钥。
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
// 使用公钥和私钥创建一个RSAKey对象,并为其分配一个随机的UUID作为keyID。
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
// 创建一个包含这个RSAKey的JWKSet对象。
JWKSet jwkSet = new JWKSet(rsaKey);
// 返回一个不可变的JWKSet对象,这通常是为了保证安全而设计的。
return new ImmutableJWKSet<>(jwkSet);
}
/**
* 私有方法,用于生成RSA密钥对。
*
* @return KeyPair
*/
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
// 实例化一个RSA密钥对生成器。
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
// 初始化密钥对生成器,指定密钥长度为2048位。
keyPairGenerator.initialize(2048);
// 生成RSA密钥对。
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
// 如果发生异常,则抛出IllegalStateException。
throw new IllegalStateException(ex);
}
// 返回生成的密钥对。
return keyPair;
}
/**
* 用于解码签名访问令牌的 JwtDecoder 实例。
*
* @param jwkSource jwk源
* @return JwtDecoder
*/
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
// 使用OAuth2AuthorizationServerConfiguration类中的静态方法jwtDecoder,
// 根据提供的JWKSource对象创建JwtDecoder。
// JwtDecoder是用于解码JWT的组件。
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
/**
* 配置OAuth 2.0授权服务器。
*
* @return AuthorizationServerSettings
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
// 创建一个AuthorizationServerSettings对象,并使用默认设置。
// 这个对象通常用于配置授权服务器的各种设置。
return AuthorizationServerSettings.builder().build();
}
}资源服务器
application.yml
在这里的application.yml中,我们需要添加一个与我们的授权服务器的内省端点相对应的内省 uri 。这是验证不透明令牌的方式
yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000yaml
spring:
security:
oauth2:
resourceserver:
opaquetoken:
client-id: messaging-client
client-secret: secret
introspection-uri: http://localhost:9000/oauth2/introspectResourceServerConfig.java
java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message.read")
.requestMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message.write")
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2
.jwt(Customizer.withDefaults())
);
// @formatter:on
return http.build();
}
}java
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(HttpMethod.GET, "/message/**").hasAuthority("SCOPE_message.read")
.requestMatchers(HttpMethod.POST, "/message/**").hasAuthority("SCOPE_message.write")
.anyRequest().authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2
.opaqueToken(Customizer.withDefaults())
);
// @formatter:on
return http.build();
}
}MessagesController.java
java
@GetMapping("/")
public String index(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) {
return String.format("Hello, %s!", (String) principal.getAttribute("sub"));
}Oauth2 认证流程
用户请求 resouece-server 过程
- user
- BearerTokenAuthenticationFilter: 解析 Authentication: Bearer {token} 中的token
- 交给 OpaqueTokenAuthenticationProvider
- OpaqueTokenAuthenticationProvider 委托 OpaqueTokenIntrospector 的 introspect 去校验 token
- OpaqueTokenIntrospector
- SpringOpaqueTokenIntrospector: OpaqueTokenIntrospector的实现
- 添加 BasicAuthenticationInterceptor 拦截器
- 在认证之前,需要对该resource-server 的 client_id 和client_secret 进行认证
- 在Header 上添加 Authentication: Basic
{Base64.encode(client_id:client_secret)} - 在authentication-server 会被 OAuth2ClientAuthenticationFilter 拦截
- RestTemplate
- 通过 RestTemplate 去请求 authentication-server 的
/oauth2/introspect - 在authentication-server 会被 OAuth2TokenIntrospectionEndpointFilter 拦截
- 通过 RestTemplate 去请求 authentication-server 的
- 添加 BasicAuthenticationInterceptor 拦截器
- SpringOpaqueTokenIntrospector: OpaqueTokenIntrospector的实现
authentication-server 认证过程:
- OAuth2ClientAuthenticationFilter
- OAuth2ClientAuthenticationFilter 的 ClientSecretBasicAuthenticationConverter 会检索出 Authentication 中 Basic 的token
- 委托 ClientSecretAuthenticationProvider 对 client_id 和 client_secret 进行认证
- 认证成功,交给下一个Filter
- OAuth2TokenIntrospectionEndpointFilter
- 拦截·
/oauth2/introspect请求 - 委托 OAuth2TokenIntrospectionAuthenticationProvider 认证
- OAuth2TokenIntrospectionAuthenticationProvider
- 通过
OAuth2AuthorizationService根据 token 到数据库查询 是否有 认证过的 OAuth2Authorization - 返回 OAuth2TokenIntrospectionAuthenticationToken
- 通过
- OAuth2TokenIntrospectionAuthenticationProvider
- 拦截·
结论
在本文中,我们学习了如何配置基于 Spring Security 的资源服务器应用程序来验证不透明令牌。在使用令牌内省会导致 OAuth 2.0 系统内的网络流量增加。为了解决 这个问题,我们可以允许受保护资源缓存给定令牌的内省请求结果。建议设置短于令牌生命周期的缓存有效期,以便降低令牌被撤回但缓存还有效的可能性。