SpringBoot3.X配置OAuth的代碼實(shí)踐
背景
之前在學(xué)習(xí)OAuth2時(shí),我就有一個(gè)疑惑,OAuth2中有太多的配置、服務(wù)類都標(biāo)注了@Deprecated,如下:
顯然這些寫(xiě)法已經(jīng)過(guò)時(shí)了,那么官方推薦的最新寫(xiě)法是什么樣的呢?當(dāng)時(shí)我沒(méi)有深究這些,我以為我放過(guò)了它,它就能放過(guò)我,誰(shuí)曾想不久之后,命運(yùn)的大手不由分說(shuō)的攥緊了我,讓我不得不直面自己的困惑。
最近我接了個(gè)大活,對(duì)公司的Java后端技術(shù)框架進(jìn)行版本升級(jí),將SpringBoot的版本從2.X升到3.X,JDK從1.8升到17,在對(duì)框架的父工程中的依賴版本進(jìn)行升級(jí)之后,接下來(lái)要做的就是對(duì)已有的公共服務(wù)/組件進(jìn)行升級(jí)了,比如GateWay, 流程引擎,基礎(chǔ)平臺(tái),認(rèn)證服務(wù)等。其他的服務(wù)升級(jí)都還算有驚無(wú)險(xiǎn),但是升級(jí)認(rèn)證服務(wù)OAuth時(shí),不夸張的說(shuō),我真是被折騰得死去活來(lái)。
相比于SpringBoot2.X,3.X對(duì)于OAuth的配置幾乎是進(jìn)行了巔覆式的變更,很多之前我們熟知的配置方法,要么是換了形式,要么是換了位置,想要配得和2.X一樣的效果太難了。好在經(jīng)歷了一番坎坷后,我終于把它給整理出來(lái)了,借著OAuth升版的機(jī)會(huì),我也終于弄明白了最版的配置是什么樣的。
代碼實(shí)踐
伴隨著JDK和SpringBoot的版本升級(jí),Spring Security也需要進(jìn)行相應(yīng)的升級(jí),這直接導(dǎo)致了適用于SpringBoot2.X的相關(guān)OAuth配置變得不可用,甚至我們耳熟能詳?shù)呐渲妙惾鏏uthorizationServerConfigurerAdapter, WebSecurityConfigurerAdapter等都被刪除了,下面就對(duì)比著SpringBoot2.X,詳細(xì)說(shuō)下3.X中對(duì)于配置做了哪些變更。
一、依賴包的變化
在SpringBoot2.X中要實(shí)現(xiàn)OAuth服務(wù),需要引入以下依賴:
<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依賴包中,僅實(shí)現(xiàn)了授權(quán)碼模式,要想使用之前的用戶名密碼模式,客戶端模式等,還需要手動(dòng)擴(kuò)展,擴(kuò)展模式需要實(shí)現(xiàn)這三個(gè)接口:
AuthenticationConverter (用于將認(rèn)證請(qǐng)求轉(zhuǎn)換為標(biāo)準(zhǔn)的 Authentication 對(duì)象)
AuthenticationProvider (用于定義如何驗(yàn)證用戶的認(rèn)證信息)
OAuth2AuthorizationGrantAuthenticationToken(將認(rèn)證對(duì)象轉(zhuǎn)換為系統(tǒng)內(nèi)部可識(shí)別的形式)
三、數(shù)據(jù)庫(kù)表的變化
SpringBoot2.X版本時(shí),OAuth存儲(chǔ)客戶信息的表結(jié)構(gòu)如下:
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) );
升級(jí)為SpringBoot3.X后,客戶信息表結(jié)構(gòu)如下:
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服務(wù)中,相關(guān)的認(rèn)證接接口的url都是/oauth/*,如/oauth/token /oauth/authorize,而升級(jí)到新版后,所有接口的url都變成了/oauth2/*,在配置客戶端時(shí)需要格外注意。
五、配置的變化
接下來(lái)就是重頭戲:配置的變化,為了更直觀的展示SprinBoot在2.X和3.X對(duì)于配置的變化,我將把一套2.X的OAuth配置以及它轉(zhuǎn)換成3.X的配置都貼出來(lái),配置中涉及認(rèn)證自動(dòng)審批、內(nèi)存模式和數(shù)據(jù)庫(kù)模式,Token的過(guò)期時(shí)間,Token的JWT轉(zhuǎn)換,Password的加密,自定義登陸頁(yè),客戶端的授權(quán)方式等。
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; /** * 這個(gè)方法是對(duì)客戶端進(jìn)行配置,比如秘鑰,唯一id,,一個(gè)驗(yàn)證服務(wù)器可以預(yù)設(shè)很多個(gè)客戶端, * 之后這些指定的客戶端就可以按照下面指定的方式進(jìn)行驗(yàn)證 * @param clients 客戶端配置工具 */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.jdbc(dataSource); } /** * 以內(nèi)存的方式設(shè)置客戶端方法 @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients .inMemory() //這里我們直接硬編碼創(chuàng)建,當(dāng)然也可以像Security那樣自定義或是使用JDBC從數(shù)據(jù)庫(kù)讀取 .withClient("client") //客戶端名稱,隨便起就行 .secret(encoder.encode("123456")) //只與客戶端分享的secret,隨便寫(xiě),但是注意要加密 .autoApprove(false) //自動(dòng)審批,這里關(guān)閉,要的就是一會(huì)體驗(yàn)?zāi)欠N感覺(jué) .scopes("read", "write") //授權(quán)范圍,這里我們使用全部all .autoApprove(true) // 這個(gè)為true時(shí),可以自動(dòng)授權(quán)。 .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"); //授權(quán)模式,一共支持5種,除了之前我們介紹的四種之外,還有一個(gè)刷新Token的模式 } */ // 令牌端點(diǎn)的安全配置,比如/oauth/token對(duì)哪些開(kāi)放 @Override public void configure(AuthorizationServerSecurityConfigurer security) { security .passwordEncoder(encoder) //編碼器設(shè)定為BCryptPasswordEncoder .allowFormAuthenticationForClients() //允許客戶端使用表單驗(yàn)證,一會(huì)我們POST請(qǐng)求中會(huì)攜帶表單信息 .checkTokenAccess("permitAll()"); //允許所有的Token查詢請(qǐng)求 } //令牌訪問(wèn)端點(diǎn)的配置 @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints .userDetailsService(service) .authenticationManager(manager) .tokenServices(tokenServices()); //由于SpringSecurity新版本的一些底層改動(dòng),這里需要配置一下authenticationManager,才能正常使用password模式 endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access"); } // 設(shè)置token的存儲(chǔ),過(guò)期時(shí)間,添加附加信息等 @Bean public AuthorizationServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setReuseRefreshToken(true); services.setTokenStore(tokenStore); services.setAccessTokenValiditySeconds(120); // 設(shè)置令牌有效時(shí)間 services.setRefreshTokenValiditySeconds(60*5); //設(shè)計(jì)刷新令牌的有效時(shí)間 TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain(); tokenEnhancerChain.setTokenEnhancers(Arrays.asList(new CustomTokenEnhancer(), accessTokenConverter())); services.setTokenEnhancer(tokenEnhancerChain); return services; } // 對(duì)token信息進(jìn)行JWT加密 @Bean public JwtAccessTokenConverter accessTokenConverter() { // 將自定義的內(nèi)容封裝到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"; /** * 注意,當(dāng)在內(nèi)存中獲取用戶信息時(shí),就不需要?jiǎng)?chuàng)建UserDetailService的實(shí)現(xiàn)類了 * */ @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相關(guān)和登錄登錄相關(guān)的url,其他的交給資源服務(wù)處理 .authorizeRequests() .antMatchers( "/oauth/**","/**/*.css", "/**/*.ico", "/**/*.png", "/**/*.jpg", "/**/*.svg", "/login", "/**/*.js", "/**/*.map",loginUrl, "/user/*","/base-grant.html") .permitAll() .anyRequest() .authenticated(); // post請(qǐng)求要設(shè)置允許跨域,然后會(huì)報(bào)401 http.csrf().ignoringAntMatchers("/login", "/logout", "/unlock/apply"); // 表單登錄 http.formLogin() // 登錄頁(yè)面 .loginPage(loginUrl) // 登錄處理url .loginProcessingUrl("/login"); http.httpBasic(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userService).passwordEncoder(passwordEncoder()); } /** * 以內(nèi)存的方式載入用戶信息 @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); auth.inMemoryAuthentication() //直接創(chuàng)建一個(gè)靜態(tài)用戶 .passwordEncoder(encoder) .withUser("leixi").password(encoder.encode("123456")).roles("USER"); } @Bean @Override public UserDetailsService userDetailsServiceBean() throws Exception { return super.userDetailsServiceBean(); } */ @Bean //這里需要將AuthenticationManager注冊(cè)為Bean,在OAuth配置中使用 @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } //通過(guò)redis存儲(chǔ)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", "測(cè)試在accessToken中添加附加信息"); mapResp.put("authorities","hahahaha"); } } catch (Exception e) { e.printStackTrace(); } return mapResp; } } /** * 密碼實(shí)現(xiàn)類,允許開(kāi)發(fā)人員自由設(shè)置密碼加密 * * @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)); } }
看得出來(lái),SpringBoot2.X中SpringSecurityConfig的配置與OAuth2Configuration的配置有種相輔相成的感覺(jué),但對(duì)于初學(xué)者來(lái)說(shuō),會(huì)覺(jué)得很割裂,不知道哪些東西該配在哪個(gè)文件里。
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; } /** * 在內(nèi)存中獲取用戶信息的方式 @Bean public UserDetailsService userDetailsService() { UserDetails userDetails = User.builder() .username("leixi") .roles("USER") .password(passwordEncoder.encode("123456")) .build(); return new InMemoryUserDetailsManager(userDetails); } */ /** * 在內(nèi)存中獲取客戶端信息的方式,還可以用于客戶端信息的入庫(kù) * @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") // 登錄成功后對(duì)scope進(jìn)行確認(rèn)授權(quán) .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); //客戶端信息入庫(kù) 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() // 允許所有用戶訪問(wèn)這些路徑 .anyRequest().authenticated() ); http.csrf(csrf -> csrf.ignoringRequestMatchers("/login", "/logout", "/unlock/apply")); // 禁用CSRF保護(hù) // 表單登錄 http.formLogin(formlogin -> formlogin .loginPage(loginUrl) .loginProcessingUrl("/login")) .httpBasic(httpBasic -> {}) .authenticationProvider(daoAuthenticationProvider()); return http.build(); } @Bean public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider customerDaoAuthenticationProvider = new DaoAuthenticationProvider(); // 設(shè)置userDetailsService customerDaoAuthenticationProvider.setUserDetailsService(userDetailService); // 禁止隱藏用戶未找到異常 customerDaoAuthenticationProvider.setHideUserNotFoundExceptions(false); // 使用MD5進(jìn)行密碼的加密 customerDaoAuthenticationProvider.setPasswordEncoder(passwordEncoder); return customerDaoAuthenticationProvider; } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .build(); } @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { //應(yīng)用了默認(rèn)的安全配置,這些配置支持OAuth2授權(quán)服務(wù)器的功能。 OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) // 自定義用戶名密碼的授權(quán)方式 .tokenEndpoint((tokenEndpoint) -> tokenEndpoint .accessTokenRequestConverter(new DelegatingAuthenticationConverter(Arrays.asList( new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), new OAuth2ClientCredentialsAuthenticationConverter(), new OAuth2PasswordAuthenticationConverter() //添加密碼模式的授權(quán)方式 ))).authenticationProviders((customProviders) -> { // 自定義認(rèn)證提供者 customProviders.add(new OAuth2PasswordAuthenticationProvider(jwkSource(), userDetailService, passwordEncoder)); }) ) //啟用了OpenID Connect 1.0,這是一種基于OAuth2的身份驗(yàn)證協(xié)議。 .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 //配置了當(dāng)用戶嘗試訪問(wèn)受保護(hù)資源但未認(rèn)證時(shí)的行為。設(shè)置了一個(gè)自定義的登錄頁(yè)面作為認(rèn)證入口點(diǎn)。 http.exceptionHandling((exceptions) -> exceptions .authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint(loginUrl)) ) //配置了OAuth2資源服務(wù)器,指定使用JWT(JSON Web Token)進(jìn)行身份驗(yàn)證。 .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):簽發(fā)人/發(fā)行人 * sub (subject):主題 * aud (audience):用戶 * exp (expiration time):過(guò)期時(shí)間 * nbf (Not Before):生效時(shí)間,在此之前是無(wú)效的 * iat (Issued At):簽發(fā)時(shí)間 * jti (JWT ID):用于標(biāo)識(shí)該 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); } } // 注意,以下三個(gè)類是新版OAuth的密碼模式的實(shí)現(xiàn),不需要的可以不加 /** * * @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 */ /** * 密碼認(rèn)證的核心邏輯 */ 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(); // 校驗(yàn)賬戶 String username = passwordAuthentication.getUsername(); if (StringUtils.isEmpty(username)){ throw new OAuth2AuthenticationException("賬戶不能為空"); } // 校驗(yàn)密碼 String password = passwordAuthentication.getPassword(); if (StringUtils.isEmpty(password)){ throw new OAuth2AuthenticationException("密碼不能為空"); } // 查詢賬戶信息 UserDetails userDetails = userDetailService.loadUserByUsername(username); if (userDetails ==null) { throw new OAuth2AuthenticationException("賬戶信息不存在,請(qǐng)聯(lián)系管理員"); } // 校驗(yàn)密碼 if (!passwordEncoder.encode(password).equals(userDetails.getPassword())) { throw new OAuth2AuthenticationException("密碼不正確"); } // 構(gòu)造認(rèn)證信息 Authentication principal = new UsernamePasswordAuthenticationToken(username, userDetails.getPassword(), userDetails.getAuthorities()); //region 直接構(gòu)造一個(gè)OAuth2Authorization對(duì)象,實(shí)際場(chǎng)景中,應(yīng)該去數(shù)據(jù)庫(kù)進(jìn)行校驗(yàn) 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"); // 構(gòu)造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; } }
如果不算上擴(kuò)展的授權(quán)模式,SpringBoot3針對(duì)OAuth的配置要較之前精簡(jiǎn)了很多,而且一個(gè)配置文件就能搞定。從配置上也可以看出來(lái),新版OAuth具有很高的靈活性,允許用戶根據(jù)自己的需要來(lái)定義授權(quán)模式,對(duì)于安全性方面也有所增強(qiáng),因此有更廣闊的使用空間。
功能測(cè)試
配置好OAuth2后,驗(yàn)證配置的準(zhǔn)確性方式就是成功啟動(dòng)OAuth,且相關(guān)的授權(quán)模式可以跑通。咱們借用之前幾篇博客里寫(xiě)的client,以及PostMan,對(duì)SpringBoot3.X版的OAuth2進(jìn)行測(cè)試,測(cè)試成果如下:
1、擴(kuò)展的用戶名密碼模式,成功
2、授權(quán)碼模式,通過(guò)該問(wèn)如下鏈接獲取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,通過(guò)code來(lái)獲取token
接下來(lái),咱們對(duì)token進(jìn)行解析,檢查封裝在access_token里的信息是否存在,咱們通過(guò)之前寫(xiě)好的OAuth-Client對(duì)它進(jìn)行解析,結(jié)果如下:
通過(guò)以上測(cè)試,可知新版的配置完全達(dá)到了我們的要求。
踩坑記錄
1、也不算是坑吧,SpringBoot3.X配置OAuth的方式在網(wǎng)上的相關(guān)資料很少,而且很難搜到,所以搜索這部分內(nèi)容的資料,關(guān)鍵字很重要,一個(gè)是“Spring Security2.7”,一個(gè)是“spring-security-oauth2-authorization-server 配置”,可以搜到很多有用的信息。
2、client的配置很關(guān)鍵,我之前在接口測(cè)試時(shí),怎么都無(wú)法通過(guò),結(jié)果打斷點(diǎn)發(fā)現(xiàn)不同的client調(diào)用時(shí)支持不同的方法,而方法不對(duì),就會(huì)報(bào)invalid_client,調(diào)用方法配置如下:
3、千萬(wàn)不要用http://localhost:8080這種方式調(diào)用OAuth服務(wù),但凡遇到localhost,都會(huì)報(bào)invalid_grant等bug。
4、通過(guò)http://IP:PORT/oauth2/authorize訪問(wèn)OAuth時(shí),鏈接中一定要帶上client_id, scope,不然無(wú)法授權(quán),且鏈接中如果有redirect_uri,則redirect_uri一定要在客戶端配置的redirect_uri列表內(nèi),且通過(guò)/oauth2/authorize獲得code后,通過(guò)code來(lái)獲取token時(shí),請(qǐng)求中要有redirect_uri,且要和初始鏈接一致。
5、同一個(gè)code只能用一次,之前我調(diào)試時(shí),獲取到了code,并根據(jù)code獲得了token,結(jié)果在解析token時(shí)出了問(wèn)題,我嘗試再用那個(gè)code來(lái)獲取token時(shí)就報(bào)錯(cuò)code過(guò)期,這算是一個(gè)常識(shí)吧,希望新上手的能吸取教訓(xùn)。
6、遇到解決不了的問(wèn)題,還是debug吧,通過(guò)OAuth2ClientAuthenticationFilter可以進(jìn)入過(guò)濾器鏈,再打斷點(diǎn)一步步的調(diào)試,耐心一點(diǎn),總能找到原因的。
后記與致謝
最近一個(gè)月我都在死磕著OAuth,也是想憑著一鼓作氣,把它的運(yùn)用給一次性琢磨透徹了,然而事與愿違,越鉆研下去,越發(fā)覺(jué)得它的博大精深,感覺(jué)不能靠一天兩天就完全掌握,還是需要持續(xù)的學(xué)習(xí)和積累。之前的博客里我有提到,學(xué)習(xí)OAuth時(shí)感覺(jué)到一種深深的挫敗感,因?yàn)槲椰F(xiàn)在研究的東西,在17,18年已經(jīng)被好多人研究透了。而這兩天我又發(fā)現(xiàn)了一些變化,在SpringSecurity升級(jí)之后,很多大佬也整理了博客教新人如何使用spring-security-oauth2-authorization-server,這讓我覺(jué)得前行的道路并不孤單,以下是我覺(jué)得對(duì)我?guī)椭艽蟮牟┛停葜x大佬,感激不盡!
參考資料
Spring Boot 最新版3.x 集成 OAuth 2.0實(shí)現(xiàn)認(rèn)證授權(quán)服務(wù) (首推,我就是看他的博客才配好服務(wù)端客戶端的。)
SpringSecurity最新學(xué)習(xí),spring-security-oauth2-authorization-server
Springboot2.7 OAuth2 server使用jdbc存儲(chǔ)RegisteredClient
到此這篇關(guān)于SpringBoot3.X配置OAuth的文章就介紹到這了,更多相關(guān)SpringBoot3.X配置OAuth內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- springboot oauth2實(shí)現(xiàn)單點(diǎn)登錄實(shí)例
- Springboot開(kāi)發(fā)OAuth2認(rèn)證授權(quán)與資源服務(wù)器操作
- springboot集成springsecurity 使用OAUTH2做權(quán)限管理的教程
- 基于SpringBoot整合oauth2實(shí)現(xiàn)token認(rèn)證
- springboot2.x實(shí)現(xiàn)oauth2授權(quán)碼登陸的方法
- 詳解Springboot Oauth2 Server搭建Oauth2認(rèn)證服務(wù)
- 使用Springboot搭建OAuth2.0 Server的方法示例
- springboot+Oauth2實(shí)現(xiàn)自定義AuthenticationManager和認(rèn)證path
相關(guān)文章
Spring @RestController注解組合實(shí)現(xiàn)方法解析
這篇文章主要介紹了Spring @RestController注解組合實(shí)現(xiàn)方法解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-06-06Java基本數(shù)據(jù)類型(動(dòng)力節(jié)點(diǎn)java學(xué)院整理)
Java數(shù)據(jù)類型(type)可以分為兩大類:基本類型(primitive types)和引用類型(reference types)。下面是動(dòng)力節(jié)點(diǎn)給大家整理java基本數(shù)據(jù)類型相關(guān)知識(shí),感興趣的朋友一起學(xué)習(xí)吧2017-03-03Spring通過(guò)配置文件管理Bean對(duì)象的方法
這篇文章主要介紹了Spring通過(guò)配置文件管理Bean對(duì)象的相關(guān)知識(shí),本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-07-07java request.getParameter中文亂碼解決方法
今天跟大家分享幾個(gè)解決java Web開(kāi)發(fā)中,request.getParameter()獲取URL中文參數(shù)亂碼的解決辦法,需要的朋友可以參考下2020-02-02Springboot-dubbo-fescar 阿里分布式事務(wù)的實(shí)現(xiàn)方法
這篇文章主要介紹了Springboot-dubbo-fescar 阿里分布式事務(wù)的實(shí)現(xiàn)方法,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2019-03-03教你怎么用Java通過(guò)關(guān)鍵字修改pdf
此方法只適合通過(guò)關(guān)鍵字位置,在pdf上添加字符直接上代碼,代碼比較長(zhǎng),大部分自己的理解都在代碼注釋中了,需要的朋友可以參考下2021-05-05SpringBoot在線代碼修改器的問(wèn)題及解決方法
這篇文章主要介紹了SpringBoot在線代碼修改器的問(wèn)題及解決方法,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-06-06java讀取文件:char的ASCII碼值=65279,顯示是一個(gè)空字符的解決
這篇文章主要介紹了java讀取文件:char的ASCII碼值=65279,顯示是一個(gè)空字符的解決,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-08-08解決Mybatis中result標(biāo)簽識(shí)別不了的情況
這篇文章主要介紹了解決Mybatis中result標(biāo)簽識(shí)別不了的情況,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教。2022-01-01