SpringBoot3.X配置OAuth的代碼實(shí)踐
背景
之前在學(xué)習(xí)OAuth2時(shí),我就有一個(gè)疑惑,OAuth2中有太多的配置、服務(wù)類都標(biāo)注了@Deprecated,如下:


顯然這些寫法已經(jīng)過時(shí)了,那么官方推薦的最新寫法是什么樣的呢?當(dāng)時(shí)我沒有深究這些,我以為我放過了它,它就能放過我,誰曾想不久之后,命運(yùn)的大手不由分說的攥緊了我,讓我不得不直面自己的困惑。
最近我接了個(gè)大活,對公司的Java后端技術(shù)框架進(jìn)行版本升級,將SpringBoot的版本從2.X升到3.X,JDK從1.8升到17,在對框架的父工程中的依賴版本進(jìn)行升級之后,接下來要做的就是對已有的公共服務(wù)/組件進(jìn)行升級了,比如GateWay, 流程引擎,基礎(chǔ)平臺,認(rèn)證服務(wù)等。其他的服務(wù)升級都還算有驚無險(xiǎn),但是升級認(rèn)證服務(wù)OAuth時(shí),不夸張的說,我真是被折騰得死去活來。
相比于SpringBoot2.X,3.X對于OAuth的配置幾乎是進(jìn)行了巔覆式的變更,很多之前我們熟知的配置方法,要么是換了形式,要么是換了位置,想要配得和2.X一樣的效果太難了。好在經(jīng)歷了一番坎坷后,我終于把它給整理出來了,借著OAuth升版的機(jī)會,我也終于弄明白了最版的配置是什么樣的。
代碼實(shí)踐
伴隨著JDK和SpringBoot的版本升級,Spring Security也需要進(jìn)行相應(yīng)的升級,這直接導(dǎo)致了適用于SpringBoot2.X的相關(guān)OAuth配置變得不可用,甚至我們耳熟能詳?shù)呐渲妙惾鏏uthorizationServerConfigurerAdapter, WebSecurityConfigurerAdapter等都被刪除了,下面就對比著SpringBoot2.X,詳細(xì)說下3.X中對于配置做了哪些變更。
一、依賴包的變化
在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)碼模式,要想使用之前的用戶名密碼模式,客戶端模式等,還需要手動擴(kuò)展,擴(kuò)展模式需要實(shí)現(xiàn)這三個(gè)接口:
AuthenticationConverter (用于將認(rèn)證請求轉(zhuǎn)換為標(biāo)準(zhǔn)的 Authentication 對象)
AuthenticationProvider (用于定義如何驗(yàn)證用戶的認(rèn)證信息)
OAuth2AuthorizationGrantAuthenticationToken(將認(rèn)證對象轉(zhuǎn)換為系統(tǒng)內(nèi)部可識別的形式)
三、數(shù)據(jù)庫表的變化
SpringBoot2.X版本時(shí),OAuth存儲客戶信息的表結(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)
);升級為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,而升級到新版后,所有接口的url都變成了/oauth2/*,在配置客戶端時(shí)需要格外注意。
五、配置的變化
接下來就是重頭戲:配置的變化,為了更直觀的展示SprinBoot在2.X和3.X對于配置的變化,我將把一套2.X的OAuth配置以及它轉(zhuǎn)換成3.X的配置都貼出來,配置中涉及認(rèn)證自動審批、內(nèi)存模式和數(shù)據(jù)庫模式,Token的過期時(shí)間,Token的JWT轉(zhuǎn)換,Password的加密,自定義登陸頁,客戶端的授權(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è)方法是對客戶端進(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ù)庫讀取
.withClient("client") //客戶端名稱,隨便起就行
.secret(encoder.encode("123456")) //只與客戶端分享的secret,隨便寫,但是注意要加密
.autoApprove(false) //自動審批,這里關(guān)閉,要的就是一會體驗(yàn)?zāi)欠N感覺
.scopes("read", "write") //授權(quán)范圍,這里我們使用全部all
.autoApprove(true) // 這個(gè)為true時(shí),可以自動授權(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對哪些開放
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.passwordEncoder(encoder) //編碼器設(shè)定為BCryptPasswordEncoder
.allowFormAuthenticationForClients() //允許客戶端使用表單驗(yàn)證,一會我們POST請求中會攜帶表單信息
.checkTokenAccess("permitAll()"); //允許所有的Token查詢請求
}
//令牌訪問端點(diǎn)的配置
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.userDetailsService(service)
.authenticationManager(manager)
.tokenServices(tokenServices());
//由于SpringSecurity新版本的一些底層改動,這里需要配置一下authenticationManager,才能正常使用password模式
endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");
}
// 設(shè)置token的存儲,過期時(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;
}
// 對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í),就不需要創(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請求要設(shè)置允許跨域,然后會報(bào)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());
}
/**
* 以內(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注冊為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;
}
}
/**
* 密碼實(shí)現(xiàn)類,允許開發(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));
}
}看得出來,SpringBoot2.X中SpringSecurityConfig的配置與OAuth2Configuration的配置有種相輔相成的感覺,但對于初學(xué)者來說,會覺得很割裂,不知道哪些東西該配在哪個(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)存中獲取客戶端信息的方式,還可以用于客戶端信息的入庫
*
@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進(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); //客戶端信息入庫
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保護(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)用戶嘗試訪問受保護(hù)資源但未認(rèn)證時(shí)的行為。設(shè)置了一個(gè)自定義的登錄頁面作為認(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):過期時(shí)間
* nbf (Not Before):生效時(shí)間,在此之前是無效的
* iat (Issued At):簽發(fā)時(shí)間
* jti (JWT ID):用于標(biāo)識該 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("賬戶信息不存在,請聯(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對象,實(shí)際場景中,應(yīng)該去數(shù)據(jù)庫進(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針對OAuth的配置要較之前精簡了很多,而且一個(gè)配置文件就能搞定。從配置上也可以看出來,新版OAuth具有很高的靈活性,允許用戶根據(jù)自己的需要來定義授權(quán)模式,對于安全性方面也有所增強(qiáng),因此有更廣闊的使用空間。
功能測試
配置好OAuth2后,驗(yàn)證配置的準(zhǔn)確性方式就是成功啟動OAuth,且相關(guān)的授權(quán)模式可以跑通。咱們借用之前幾篇博客里寫的client,以及PostMan,對SpringBoot3.X版的OAuth2進(jìn)行測試,測試成果如下:
1、擴(kuò)展的用戶名密碼模式,成功

2、授權(quá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,通過code來獲取token

接下來,咱們對token進(jìn)行解析,檢查封裝在access_token里的信息是否存在,咱們通過之前寫好的OAuth-Client對它進(jìn)行解析,結(jié)果如下:

通過以上測試,可知新版的配置完全達(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)鍵,我之前在接口測試時(shí),怎么都無法通過,結(jié)果打斷點(diǎn)發(fā)現(xiàn)不同的client調(diào)用時(shí)支持不同的方法,而方法不對,就會報(bào)invalid_client,調(diào)用方法配置如下:

3、千萬不要用http://localhost:8080這種方式調(diào)用OAuth服務(wù),但凡遇到localhost,都會報(bào)invalid_grant等bug。
4、通過http://IP:PORT/oauth2/authorize訪問OAuth時(shí),鏈接中一定要帶上client_id, scope,不然無法授權(quán),且鏈接中如果有redirect_uri,則redirect_uri一定要在客戶端配置的redirect_uri列表內(nèi),且通過/oauth2/authorize獲得code后,通過code來獲取token時(shí),請求中要有redirect_uri,且要和初始鏈接一致。
5、同一個(gè)code只能用一次,之前我調(diào)試時(shí),獲取到了code,并根據(jù)code獲得了token,結(jié)果在解析token時(shí)出了問題,我嘗試再用那個(gè)code來獲取token時(shí)就報(bào)錯code過期,這算是一個(gè)常識吧,希望新上手的能吸取教訓(xùn)。
6、遇到解決不了的問題,還是debug吧,通過OAuth2ClientAuthenticationFilter可以進(jìn)入過濾器鏈,再打斷點(diǎn)一步步的調(diào)試,耐心一點(diǎn),總能找到原因的。
后記與致謝
最近一個(gè)月我都在死磕著OAuth,也是想憑著一鼓作氣,把它的運(yùn)用給一次性琢磨透徹了,然而事與愿違,越鉆研下去,越發(fā)覺得它的博大精深,感覺不能靠一天兩天就完全掌握,還是需要持續(xù)的學(xué)習(xí)和積累。之前的博客里我有提到,學(xué)習(xí)OAuth時(shí)感覺到一種深深的挫敗感,因?yàn)槲椰F(xiàn)在研究的東西,在17,18年已經(jīng)被好多人研究透了。而這兩天我又發(fā)現(xiàn)了一些變化,在SpringSecurity升級之后,很多大佬也整理了博客教新人如何使用spring-security-oauth2-authorization-server,這讓我覺得前行的道路并不孤單,以下是我覺得對我?guī)椭艽蟮牟┛?,拜謝大佬,感激不盡!
參考資料
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存儲RegisteredClient
到此這篇關(guān)于SpringBoot3.X配置OAuth的文章就介紹到這了,更多相關(guān)SpringBoot3.X配置OAuth內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- SpringBoot的Security和OAuth2的使用示例小結(jié)
- 使用Springboot實(shí)現(xiàn)OAuth服務(wù)的示例詳解
- SpringBoot淺析安全管理之OAuth2框架
- springboot oauth2實(shí)現(xiàn)單點(diǎn)登錄實(shí)例
- 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.0的實(shí)現(xiàn)示例
相關(guān)文章
springboot swagger 接口文檔分組展示功能實(shí)現(xiàn)
這篇文章主要介紹了springboot swagger 接口文檔分組展示功能實(shí)現(xiàn),本文通過實(shí)例代碼給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-03-03
Java基礎(chǔ)之spring5新功能學(xué)習(xí)
這篇文章主要介紹了Java基礎(chǔ)之spring5新功能學(xué)習(xí),文中有非常詳細(xì)的代碼示例,對正在學(xué)習(xí)java基礎(chǔ)的小伙伴們有很好的幫助,需要的朋友可以參考下2021-05-05
使用IntelliJ?IDEA創(chuàng)建簡單的Java?Web項(xiàng)目完整步驟
這篇文章主要介紹了如何使用IntelliJ?IDEA創(chuàng)建一個(gè)簡單的JavaWeb項(xiàng)目,實(shí)現(xiàn)登錄、注冊和查看用戶列表功能,使用Servlet和JSP技術(shù),文中通過代碼介紹的非常詳細(xì),需要的朋友可以參考下2025-01-01
JVM?運(yùn)行時(shí)數(shù)據(jù)區(qū)與JMM?內(nèi)存模型
這篇文章主要介紹了JVM?運(yùn)行時(shí)數(shù)據(jù)區(qū)與JMM?內(nèi)存模型,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值。需要的朋友可以參考一下2022-07-07
SpringBoot項(xiàng)目中同時(shí)操作多個(gè)數(shù)據(jù)庫的實(shí)現(xiàn)方法
在實(shí)際項(xiàng)目開發(fā)中可能存在需要同時(shí)操作兩個(gè)數(shù)據(jù)庫的場景,本文主要介紹了SpringBoot項(xiàng)目中同時(shí)操作多個(gè)數(shù)據(jù)庫的實(shí)現(xiàn)方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-03-03
MybatisPlus查詢數(shù)據(jù)日期格式化問題解決方法
MyBatisPlus是MyBatis的增強(qiáng)工具,支持常規(guī)的CRUD操作以及復(fù)雜的聯(lián)表查詢等功能,這篇文章主要給大家介紹了關(guān)于MybatisPlus查詢數(shù)據(jù)日期格式化問題的解決方法,需要的朋友可以參考下2023-10-10
如何對?Excel?表格中提取的數(shù)據(jù)進(jìn)行批量更新
這篇文章主要介紹了如何對Excel表格中提取的數(shù)據(jù)進(jìn)行批量更新操作,本文通過示例代碼介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-06-06

