SpringBoot3.X配置OAuth的代码实践
作者:雷袭月启
背景
之前在学习OAuth2时,我就有一个疑惑,OAuth2中有太多的配置、服务类都标注了@Deprecated,如下:
显然这些写法已经过时了,那么官方推荐的最新写法是什么样的呢?当时我没有深究这些,我以为我放过了它,它就能放过我,谁曾想不久之后,命运的大手不由分说的攥紧了我,让我不得不直面自己的困惑。
最近我接了个大活,对公司的Java后端技术框架进行版本升级,将SpringBoot的版本从2.X升到3.X,JDK从1.8升到17,在对框架的父工程中的依赖版本进行升级之后,接下来要做的就是对已有的公共服务/组件进行升级了,比如GateWay, 流程引擎,基础平台,认证服务等。其他的服务升级都还算有惊无险,但是升级认证服务OAuth时,不夸张的说,我真是被折腾得死去活来。
相比于SpringBoot2.X,3.X对于OAuth的配置几乎是进行了巅覆式的变更,很多之前我们熟知的配置方法,要么是换了形式,要么是换了位置,想要配得和2.X一样的效果太难了。好在经历了一番坎坷后,我终于把它给整理出来了,借着OAuth升版的机会,我也终于弄明白了最版的配置是什么样的。
代码实践
伴随着JDK和SpringBoot的版本升级,Spring Security也需要进行相应的升级,这直接导致了适用于SpringBoot2.X的相关OAuth配置变得不可用,甚至我们耳熟能详的配置类如AuthorizationServerConfigurerAdapter, WebSecurityConfigurerAdapter等都被删除了,下面就对比着SpringBoot2.X,详细说下3.X中对于配置做了哪些变更。
一、依赖包的变化
在SpringBoot2.X中要实现OAuth服务,需要引入以下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.3.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> <version>2.2.5.RELEASE</version> </dependency>
而在SpringBoot3.X中,需要引入以下依赖包:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-core</artifactId> </dependency>
二、支持模式的变化
新版的spring-security-oauth2-authorization-server依赖包中,仅实现了授权码模式,要想使用之前的用户名密码模式,客户端模式等,还需要手动扩展,扩展模式需要实现这三个接口:
AuthenticationConverter (用于将认证请求转换为标准的 Authentication 对象)
AuthenticationProvider (用于定义如何验证用户的认证信息)
OAuth2AuthorizationGrantAuthenticationToken(将认证对象转换为系统内部可识别的形式)
三、数据库表的变化
SpringBoot2.X版本时,OAuth存储客户信息的表结构如下:
create table oauth_client_details ( client_id VARCHAR(256) PRIMARY KEY, resource_ids VARCHAR(256), client_secret VARCHAR(256), scope VARCHAR(256), authorized_grant_types VARCHAR(256), web_server_redirect_uri VARCHAR(256), authorities VARCHAR(256), access_token_validity INTEGER, refresh_token_validity INTEGER, additional_information VARCHAR(4096), autoapprove VARCHAR(256) );
升级为SpringBoot3.X后,客户信息表结构如下:
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, scopes varchar(1000) NOT NULL, client_settings varchar(2000) NOT NULL, token_settings varchar(2000) NOT NULL, PRIMARY KEY (id) );
四、链接的变化
旧版本的OAuth服务中,相关的认证接接口的url都是/oauth/*,如/oauth/token /oauth/authorize,而升级到新版后,所有接口的url都变成了/oauth2/*,在配置客户端时需要格外注意。
五、配置的变化
接下来就是重头戏:配置的变化,为了更直观的展示SprinBoot在2.X和3.X对于配置的变化,我将把一套2.X的OAuth配置以及它转换成3.X的配置都贴出来,配置中涉及认证自动审批、内存模式和数据库模式,Token的过期时间,Token的JWT转换,Password的加密,自定义登陆页,客户端的授权方式等。
1、SpringBoot2.X的配置
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices; import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter; import org.springframework.security.oauth2.provider.token.DefaultTokenServices; import org.springframework.security.oauth2.provider.token.TokenEnhancerChain; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; import javax.annotation.Resource; import javax.sql.DataSource; import java.util.Arrays; /** * * @author leixiyueqi * @since 2023/12/3 22:00 */ @EnableAuthorizationServer @Configuration public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter { @Resource private AuthenticationManager manager; private final MD5PasswordEncoder encoder = new MD5PasswordEncoder(); @Resource UserDetailsService service; @Resource private DataSource dataSource; @Resource TokenStore tokenStore; /** * 这个方法是对客户端进行配置,比如秘钥,唯一id,,一个验证服务器可以预设很多个客户端, * 之后这些指定的客户端就可以按照下面指定的方式进行验证 * @param clients 客户端配置工具 */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource); } /** * 以内存的方式设置客户端方法 @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients .inMemory() //这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取 .withClient("client") //客户端名称,随便起就行 .secret(encoder.encode("123456")) //只与客户端分享的secret,随便写,但是注意要加密 .autoApprove(false) //自动审批,这里关闭,要的就是一会体验那种感觉 .scopes("read", "write") //授权范围,这里我们使用全部all .autoApprove(true) // 这个为true时,可以自动授权。 .redirectUris("http://127.0.0.1:19210/leixi/login/oauth2/code/leixi-client", "http://127.0.0.1:8081/login/oauth2/code/client-id-1", "http://127.0.0.1:19210/leixi/callback") .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token"); //授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式 } */ // 令牌端点的安全配置,比如/oauth/token对哪些开放 @Override public void configure(AuthorizationServerSecurityConfigurer security) { security .passwordEncoder(encoder) //编码器设定为BCryptPasswordEncoder .allowFormAuthenticationForClients() //允许客户端使用表单验证,一会我们POST请求中会携带表单信息 .checkTokenAccess("permitAll()"); //允许所有的Token查询请求 } //令牌访问端点的配置 @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints .userDetailsService(service) .authenticationManager(manager) .tokenServices(tokenServices()); //由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式 endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access"); } // 设置token的存储,过期时间,添加附加信息等 @Bean public AuthorizationServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setReuseRefreshToken(true); services.setTokenStore(tokenStore); services.setAccessTokenValiditySeconds(120); // 设置令牌有效时间 services.setRefreshTokenValiditySeconds(60*5); //设计刷新令牌的有效时间 TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(new CustomTokenEnhancer(), accessTokenConverter())); services.setTokenEnhancer(tokenEnhancerChain); return services; } // 对token信息进行JWT加密 @Bean public JwtAccessTokenConverter accessTokenConverter() { // 将自定义的内容封装到access_token中 DefaultAccessTokenConverter defaultAccessTokenConverter = new DefaultAccessTokenConverter(); defaultAccessTokenConverter.setUserTokenConverter(new CustomerUserAuthenticationConverter()); JwtAccessTokenConverter converter = new JwtAccessTokenConverter(); converter.setAccessTokenConverter(defaultAccessTokenConverter); converter.setSigningKey("密钥"); return converter; } } import com.leixi.auth2.service.UserDetailServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore; /** * * @author leixiyueqi * @since 2023/12/3 22:00 */ @Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { private static final String loginUrl = "/login"; /** * 注意,当在内存中获取用户信息时,就不需要创建UserDetailService的实现类了 * */ @Autowired private UserDetailServiceImpl userService; @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public MD5PasswordEncoder passwordEncoder() { return new MD5PasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { http // http security 要拦截的url,这里这拦截,oauth2相关和登录登录相关的url,其他的交给资源服务处理 .authorizeRequests() .antMatchers( "/oauth/**","/**/*.css", "/**/*.ico", "/**/*.png", "/**/*.jpg", "/**/*.svg", "/login", "/**/*.js", "/**/*.map",loginUrl, "/user/*","/base-grant.html") .permitAll() .anyRequest() .authenticated(); // post请求要设置允许跨域,然后会报401 http.csrf().ignoringAntMatchers("/login", "/logout", "/unlock/apply"); // 表单登录 http.formLogin() // 登录页面 .loginPage(loginUrl) // 登录处理url .loginProcessingUrl("/login"); http.httpBasic(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService).passwordEncoder(passwordEncoder()); } /** * 以内存的方式载入用户信息 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); auth.inMemoryAuthentication() //直接创建一个静态用户 .passwordEncoder(encoder) .withUser("leixi").password(encoder.encode("123456")).roles("USER"); } @Bean @Override public UserDetailsService userDetailsServiceBean() throws Exception { return super.userDetailsServiceBean(); } */ @Bean //这里需要将AuthenticationManager注册为Bean,在OAuth配置中使用 @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } //通过redis存储token @Bean public TokenStore tokenStore() { return new RedisTokenStore(redisConnectionFactory); } } import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.provider.token.DefaultUserAuthenticationConverter; import java.util.Map; public class CustomerUserAuthenticationConverter extends DefaultUserAuthenticationConverter { @Override public Map<String, ?> convertUserAuthentication(Authentication authentication) { Map mapResp = super.convertUserAuthentication(authentication); try { UserDetails user = (UserDetails)authentication.getPrincipal(); if (user != null) { mapResp.put("loginName", user.getUsername()); mapResp.put("content", "测试在accessToken中添加附加信息"); mapResp.put("authorities","hahahaha"); } } catch (Exception e) { e.printStackTrace(); } return mapResp; } } /** * 密码实现类,允许开发人员自由设置密码加密 * * @author leixiyueqi * @since 2023/12/3 22:00 */ public class MD5PasswordEncoder implements PasswordEncoder { @Override public String encode(CharSequence rawPassword) { try { MessageDigest md5 = MessageDigest.getInstance("MD5"); byte[] digest = md5.digest(rawPassword.toString().getBytes("UTF-8")); String pass = new String(Hex.encode(digest)); return pass; } catch (Exception e) { throw new RuntimeException("Failed to encode password.", e); } } @Override public boolean matches(CharSequence rawPassword, String encodedPassword) { return encodedPassword.equals(encode(rawPassword)); } }
看得出来,SpringBoot2.X中SpringSecurityConfig的配置与OAuth2Configuration的配置有种相辅相成的感觉,但对于初学者来说,会觉得很割裂,不知道哪些东西该配在哪个文件里。
2、Springboot3.X的配置
package com.leixi.auth2.config; import com.leixi.auth2.custom.OAuth2PasswordAuthenticationConverter; import com.leixi.auth2.custom.OAuth2PasswordAuthenticationProvider; 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 jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 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.UserDetailsService; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; 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.token.DelegatingOAuth2TokenGenerator; import org.springframework.security.oauth2.server.authorization.token.JwtGenerator; import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator; import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; import java.util.UUID; /** * OAuth的配置 * * @author leixiyueqi * @since 2024/9/28 22:00 */ @Configuration @EnableWebSecurity public class OAuth2JdbcConfiguration { @Autowired private MD5PasswordEncoder passwordEncoder; @Resource private UserDetailsService userDetailService; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private CustomTokenEnhancer customTokenEnhancer; private static final String loginUrl = "/loginpage.html"; @Bean public RegisteredClientRepository registeredClientRepository() { JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); return jdbcRegisteredClientRepository; } /** * 在内存中获取用户信息的方式 @Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User.builder() .username("leixi") .roles("USER") .password(passwordEncoder.encode("123456")) .build(); return new InMemoryUserDetailsManager(userDetails); } */ /** * 在内存中获取客户端信息的方式,还可以用于客户端信息的入库 * @Bean public RegisteredClientRepository registeredClientRepository() { JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("client") .clientSecret(passwordEncoder.encode( "123456")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .authorizationGrantType(AuthorizationGrantType.PASSWORD) .redirectUri("http://127.0.0.1:19210/leixi/login/oauth2/code/leixi-client") .redirectUri("http://127.0.0.1:8081/login/oauth2/code/client-id-1") .redirectUri("http://127.0.0.1:19210/leixi/callback") .scope("read") .scope("write") // 登录成功后对scope进行确认授权 .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) .tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofHours(24)) .refreshTokenTimeToLive(Duration.ofHours(24)).build()) .build(); jdbcRegisteredClientRepository.save(registeredClient); //客户端信息入库 return new InMemoryRegisteredClientRepository(registeredClient); } */ @Bean public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((requests) -> requests .requestMatchers( "/oauth/*","/*/*.css", "/*/*.ico", "/*/*.png", "/*/*.jpg", "/*/*.svg", "/login", "/*/*.js", "/*/*.map",loginUrl, "/user/*","/base-grant.html").permitAll() // 允许所有用户访问这些路径 .anyRequest().authenticated() ); http.csrf(csrf -> csrf.ignoringRequestMatchers("/login", "/logout", "/unlock/apply")); // 禁用CSRF保护 // 表单登录 http.formLogin(formlogin -> formlogin .loginPage(loginUrl) .loginProcessingUrl("/login")) .httpBasic(httpBasic -> {}) .authenticationProvider(daoAuthenticationProvider()); return http.build(); } @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider customerDaoAuthenticationProvider = new DaoAuthenticationProvider(); // 设置userDetailsService customerDaoAuthenticationProvider.setUserDetailsService(userDetailService); // 禁止隐藏用户未找到异常 customerDaoAuthenticationProvider.setHideUserNotFoundExceptions(false); // 使用MD5进行密码的加密 customerDaoAuthenticationProvider.setPasswordEncoder(passwordEncoder); return customerDaoAuthenticationProvider; } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .build(); } @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { //应用了默认的安全配置,这些配置支持OAuth2授权服务器的功能。 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // 自定义用户名密码的授权方式 .tokenEndpoint((tokenEndpoint) -> tokenEndpoint .accessTokenRequestConverter(new DelegatingAuthenticationConverter(Arrays.asList( new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), new OAuth2ClientCredentialsAuthenticationConverter(), new OAuth2PasswordAuthenticationConverter() //添加密码模式的授权方式 ))).authenticationProviders((customProviders) -> { // 自定义认证提供者 customProviders.add(new OAuth2PasswordAuthenticationProvider(jwkSource(), userDetailService, passwordEncoder)); }) ) //启用了OpenID Connect 1.0,这是一种基于OAuth2的身份验证协议。 .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 //配置了当用户尝试访问受保护资源但未认证时的行为。设置了一个自定义的登录页面作为认证入口点。 http.exceptionHandling((exceptions) -> exceptions .authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint(loginUrl)) ) //配置了OAuth2资源服务器,指定使用JWT(JSON Web Token)进行身份验证。 .oauth2ResourceServer(config -> config.jwt(Customizer.withDefaults())); return http.build(); } @Bean public JwtEncoder jwtEncoder() { NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource()); return jwtEncoder; } @Bean public JwtDecoder jwtDecoder() { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource()); } @Bean public OAuth2TokenGenerator<?> tokenGenerator() { JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder()); jwtGenerator.setJwtCustomizer(customTokenEnhancer); OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); return new DelegatingOAuth2TokenGenerator( jwtGenerator, accessTokenGenerator, refreshTokenGenerator); } @Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } // 升版之后,采用RSA的方式加密token,与之前的版本有些差异,之前是采用HMAC加密 private static KeyPair generateRsaKey() { KeyPair keyPair; try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); keyPair = keyPairGenerator.generateKeyPair(); } catch (Exception ex) { throw new IllegalStateException(ex); } return keyPair; } } @Service public class CustomTokenEnhancer implements OAuth2TokenCustomizer<JwtEncodingContext> { @Resource private UserDetailsService userDetailService; @Override public void customize(JwtEncodingContext context) { UserDetails user = userDetailService.loadUserByUsername(context.getPrincipal().getName()); if (user != null) { context.getClaims().claims(claims -> { claims.put("loginName", user.getUsername()); claims.put("name", user.getUsername()); claims.put("content", "在accessToken中封装自定义信息"); claims.put("authorities", "hahahaha"); }); } } } /** * Jwt工具类 * * @author leixiyueqi * @since 2024/9/28 22:00 */ public final class JwtUtils { private JwtUtils() { } public static JwsHeader.Builder headers() { return JwsHeader.with(SignatureAlgorithm.RS256); } public static JwtClaimsSet.Builder accessTokenClaims(RegisteredClient registeredClient, String issuer, String subject, Set<String> authorizedScopes) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt .plus(registeredClient.getTokenSettings().getAccessTokenTimeToLive()); /** * iss (issuer):签发人/发行人 * sub (subject):主题 * aud (audience):用户 * exp (expiration time):过期时间 * nbf (Not Before):生效时间,在此之前是无效的 * iat (Issued At):签发时间 * jti (JWT ID):用于标识该 JWT */ // @formatter:off JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder(); if (StringUtils.hasText(issuer)) { claimsBuilder.issuer(issuer); } claimsBuilder .subject(subject) .audience(Collections.singletonList(registeredClient.getClientId())) .issuedAt(issuedAt) .expiresAt(expiresAt) .notBefore(issuedAt); if (!CollectionUtils.isEmpty(authorizedScopes)) { claimsBuilder.claim(OAuth2ParameterNames.SCOPE, authorizedScopes); claimsBuilder.claim("wangcl", "aaa"); } // @formatter:on return claimsBuilder; } } public class OAuth2EndpointUtils { public static MultiValueMap<String, String> getParameters(HttpServletRequest request) { Map<String, String[]> parameterMap = request.getParameterMap(); MultiValueMap<String, String> parameters = new LinkedMultiValueMap(parameterMap.size()); parameterMap.forEach((key, values) -> { if (values.length > 0) { String[] var3 = values; int var4 = values.length; for(int var5 = 0; var5 < var4; ++var5) { String value = var3[var5]; parameters.add(key, value); } } }); return parameters; } public static void throwError(String errorCode, String parameterName, String errorUri) { OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, errorUri); throw new OAuth2AuthenticationException(error); } } // 注意,以下三个类是新版OAuth的密码模式的实现,不需要的可以不加 /** * * @author leixiyueqi * @since 2024/9/28 22:00 */ import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.Map; /** * 从HttpServletRequest中提取username与password,传递给OAuth2PasswordAuthenticationToken */ public class OAuth2PasswordAuthenticationConverter implements AuthenticationConverter { @Override public Authentication convert(HttpServletRequest request) { String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) { return null; } Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request); String username = parameters.getFirst(OAuth2ParameterNames.USERNAME); if (!StringUtils.hasText(username) || parameters.get(OAuth2ParameterNames.USERNAME).size() != 1) { OAuth2EndpointUtils.throwError( OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.USERNAME,""); } String password = parameters.getFirst(OAuth2ParameterNames.PASSWORD); Map<String, Object> additionalParameters = new HashMap<>(); parameters.forEach((key, value) -> { if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && !key.equals(OAuth2ParameterNames.CLIENT_ID) && !key.equals(OAuth2ParameterNames.USERNAME) && !key.equals(OAuth2ParameterNames.PASSWORD)) { additionalParameters.put(key, value.get(0)); } }); return new OAuth2PasswordAuthenticationToken(username,password,clientPrincipal,additionalParameters); } } /** * * @author leixiyueqi * @since 2024/9/28 22:00 */ import com.leixi.auth2.config.MD5PasswordEncoder; import com.nimbusds.jose.jwk.source.JWKSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; 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.keygen.Base64StringKeyGenerator; import org.springframework.security.crypto.keygen.StringKeyGenerator; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import java.security.Principal; import java.util.Base64; import java.util.HashSet; import java.util.Set; import java.util.function.Supplier; /** * 从HttpServletRequest中提取username与password,传递给OAuth2PasswordAuthenticationToken */ /** * 密码认证的核心逻辑 */ public class OAuth2PasswordAuthenticationProvider implements AuthenticationProvider { private static final StringKeyGenerator DEFAULT_REFRESH_TOKEN_GENERATOR = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer = (context) -> {}; private Supplier<String> refreshTokenGenerator = DEFAULT_REFRESH_TOKEN_GENERATOR::generateKey; private AuthorizationServerSettings authorizationServerSettings; public OAuth2PasswordAuthenticationProvider(JWKSource jwkSource, UserDetailsService userDetailService, MD5PasswordEncoder passwordEncoder) { this.jwkSource = jwkSource; this.userDetailService = userDetailService; this.passwordEncoder = passwordEncoder; } private final JWKSource jwkSource; private UserDetailsService userDetailService; private MD5PasswordEncoder passwordEncoder; public OAuth2PasswordAuthenticationProvider(JWKSource jwkSource){ this.jwkSource = jwkSource; } public void setJwtCustomizer(OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer) { Assert.notNull(jwtCustomizer, "jwtCustomizer cannot be null"); this.jwtCustomizer = jwtCustomizer; } public void setRefreshTokenGenerator(Supplier<String> refreshTokenGenerator) { Assert.notNull(refreshTokenGenerator, "refreshTokenGenerator cannot be null"); this.refreshTokenGenerator = refreshTokenGenerator; } @Autowired(required = false) void setAuthorizationServerSettings(AuthorizationServerSettings authorizationServerSettings) { this.authorizationServerSettings = authorizationServerSettings; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2PasswordAuthenticationToken passwordAuthentication = (OAuth2PasswordAuthenticationToken) authentication; OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(passwordAuthentication); RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); // 校验账户 String username = passwordAuthentication.getUsername(); if (StringUtils.isEmpty(username)){ throw new OAuth2AuthenticationException("账户不能为空"); } // 校验密码 String password = passwordAuthentication.getPassword(); if (StringUtils.isEmpty(password)){ throw new OAuth2AuthenticationException("密码不能为空"); } // 查询账户信息 UserDetails userDetails = userDetailService.loadUserByUsername(username); if (userDetails ==null) { throw new OAuth2AuthenticationException("账户信息不存在,请联系管理员"); } // 校验密码 if (!passwordEncoder.encode(password).equals(userDetails.getPassword())) { throw new OAuth2AuthenticationException("密码不正确"); } // 构造认证信息 Authentication principal = new UsernamePasswordAuthenticationToken(username, userDetails.getPassword(), userDetails.getAuthorities()); //region 直接构造一个OAuth2Authorization对象,实际场景中,应该去数据库进行校验 OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) .principalName(principal.getName()) .authorizationGrantType(AuthorizationGrantType.PASSWORD) .attribute(Principal.class.getName(), principal) .attribute("scopes", registeredClient.getScopes() ) .build(); //endregion String issuer = this.authorizationServerSettings != null ? this.authorizationServerSettings.getIssuer() : null; Set<String> authorizedScopes = authorization.getAttribute("scopes"); // 构造jwt token信息 JwsHeader.Builder headersBuilder = JwtUtils.headers(); headersBuilder.header("client-id", registeredClient.getClientId()); headersBuilder.header("authorization-grant-type", passwordAuthentication.getGrantType().getValue()); JwtClaimsSet.Builder claimsBuilder = JwtUtils.accessTokenClaims(registeredClient, issuer, authorization.getPrincipalName(), authorizedScopes); // @formatter:off JwtEncodingContext context = JwtEncodingContext.with(headersBuilder, claimsBuilder) .registeredClient(registeredClient) .principal(authorization.getAttribute(Principal.class.getName())) .authorization(authorization) .authorizedScopes(authorizedScopes) .tokenType(OAuth2TokenType.ACCESS_TOKEN) .authorizationGrantType(AuthorizationGrantType.PASSWORD) .authorizationGrant(passwordAuthentication) .build(); // @formatter:on this.jwtCustomizer.customize(context); JwsHeader headers = context.getJwsHeader().build(); JwtClaimsSet claims = context.getClaims().build(); JwtEncoderParameters params = JwtEncoderParameters.from(headers, claims); NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(this.jwkSource); Jwt jwtAccessToken = jwtEncoder.encode(params); //Jwt jwtAccessToken = null; // 生成token OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwtAccessToken.getTokenValue(), jwtAccessToken.getIssuedAt(), jwtAccessToken.getExpiresAt(), authorizedScopes); return new OAuth2AccessTokenAuthenticationToken( registeredClient, clientPrincipal, accessToken); } @Override public boolean supports(Class<?> authentication) { return OAuth2PasswordAuthenticationToken.class.isAssignableFrom(authentication); } private OAuth2ClientAuthenticationToken getAuthenticatedClientElseThrowInvalidClient(Authentication authentication) { OAuth2ClientAuthenticationToken clientPrincipal = null; if (OAuth2ClientAuthenticationToken.class.isAssignableFrom(authentication.getPrincipal().getClass())) { clientPrincipal = (OAuth2ClientAuthenticationToken) authentication.getPrincipal(); } if (clientPrincipal != null && clientPrincipal.isAuthenticated()) { return clientPrincipal; } throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } } /** * * @author 雷袭月启 * @since 2024/9/28 22:00 */ import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; import java.util.Map; /** * 用于存放username与password */ public class OAuth2PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { private static final long serialVersionUID = -559176897708927684L; private final String username; private final String password; public OAuth2PasswordAuthenticationToken(String username, String password, Authentication clientPrincipal, Map<String, Object> additionalParameters) { super(AuthorizationGrantType.PASSWORD, clientPrincipal, additionalParameters); this.username = username; this.password = password; } public String getUsername() { return this.username; } public String getPassword() { return this.password; } }
如果不算上扩展的授权模式,SpringBoot3针对OAuth的配置要较之前精简了很多,而且一个配置文件就能搞定。从配置上也可以看出来,新版OAuth具有很高的灵活性,允许用户根据自己的需要来定义授权模式,对于安全性方面也有所增强,因此有更广阔的使用空间。
功能测试
配置好OAuth2后,验证配置的准确性方式就是成功启动OAuth,且相关的授权模式可以跑通。咱们借用之前几篇博客里写的client,以及PostMan,对SpringBoot3.X版的OAuth2进行测试,测试成果如下:
1、扩展的用户名密码模式,成功
2、授权码模式,通过该问如下链接获取code http://127.0.0.1:19200/oauth2/authorize?response_type=code&client_id=client&scope=read&redirect_uri=http://127.0.0.1:19210/leixi/callback
再利用postman,通过code来获取token
接下来,咱们对token进行解析,检查封装在access_token里的信息是否存在,咱们通过之前写好的OAuth-Client对它进行解析,结果如下:
通过以上测试,可知新版的配置完全达到了我们的要求。
踩坑记录
1、也不算是坑吧,SpringBoot3.X配置OAuth的方式在网上的相关资料很少,而且很难搜到,所以搜索这部分内容的资料,关键字很重要,一个是“Spring Security2.7”,一个是“spring-security-oauth2-authorization-server 配置”,可以搜到很多有用的信息。
2、client的配置很关键,我之前在接口测试时,怎么都无法通过,结果打断点发现不同的client调用时支持不同的方法,而方法不对,就会报invalid_client,调用方法配置如下:
3、千万不要用http://localhost:8080这种方式调用OAuth服务,但凡遇到localhost,都会报invalid_grant等bug。
4、通过http://IP:PORT/oauth2/authorize访问OAuth时,链接中一定要带上client_id, scope,不然无法授权,且链接中如果有redirect_uri,则redirect_uri一定要在客户端配置的redirect_uri列表内,且通过/oauth2/authorize获得code后,通过code来获取token时,请求中要有redirect_uri,且要和初始链接一致。
5、同一个code只能用一次,之前我调试时,获取到了code,并根据code获得了token,结果在解析token时出了问题,我尝试再用那个code来获取token时就报错code过期,这算是一个常识吧,希望新上手的能吸取教训。
6、遇到解决不了的问题,还是debug吧,通过OAuth2ClientAuthenticationFilter可以进入过滤器链,再打断点一步步的调试,耐心一点,总能找到原因的。
后记与致谢
最近一个月我都在死磕着OAuth,也是想凭着一鼓作气,把它的运用给一次性琢磨透彻了,然而事与愿违,越钻研下去,越发觉得它的博大精深,感觉不能靠一天两天就完全掌握,还是需要持续的学习和积累。之前的博客里我有提到,学习OAuth时感觉到一种深深的挫败感,因为我现在研究的东西,在17,18年已经被好多人研究透了。而这两天我又发现了一些变化,在SpringSecurity升级之后,很多大佬也整理了博客教新人如何使用spring-security-oauth2-authorization-server,这让我觉得前行的道路并不孤单,以下是我觉得对我帮助很大的博客,拜谢大佬,感激不尽!
参考资料
Spring Boot 最新版3.x 集成 OAuth 2.0实现认证授权服务 (首推,我就是看他的博客才配好服务端客户端的。)
SpringSecurity最新学习,spring-security-oauth2-authorization-server
Springboot2.7 OAuth2 server使用jdbc存储RegisteredClient
到此这篇关于SpringBoot3.X配置OAuth的文章就介绍到这了,更多相关SpringBoot3.X配置OAuth内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!