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 {@Resourceprivate AuthenticationManager manager;private final MD5PasswordEncoder encoder = new MD5PasswordEncoder();@ResourceUserDetailsService service;@Resourceprivate DataSource dataSource;@ResourceTokenStore tokenStore;/*** 这个方法是对客户端进行配置,比如秘钥,唯一id,,一个验证服务器可以预设很多个客户端,* 之后这些指定的客户端就可以按照下面指定的方式进行验证* @param clients 客户端配置工具*/@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {clients.jdbc(dataSource);}/*** 以内存的方式设置客户端方法@Overridepublic 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对哪些开放@Overridepublic void configure(AuthorizationServerSecurityConfigurer security) {security.passwordEncoder(encoder)    //编码器设定为BCryptPasswordEncoder.allowFormAuthenticationForClients()  //允许客户端使用表单验证,一会我们POST请求中会携带表单信息.checkTokenAccess("permitAll()");     //允许所有的Token查询请求}//令牌访问端点的配置@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) {endpoints.userDetailsService(service).authenticationManager(manager).tokenServices(tokenServices());//由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式endpoints.pathMapping("/oauth/confirm_access","/custom/confirm_access");}// 设置token的存储,过期时间,添加附加信息等@Beanpublic 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加密@Beanpublic 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的实现类了* */@Autowiredprivate UserDetailServiceImpl userService;@Autowiredprivate RedisConnectionFactory redisConnectionFactory;@Beanpublic MD5PasswordEncoder passwordEncoder() {return new MD5PasswordEncoder();}@Overrideprotected 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请求要设置允许跨域,然后会报401http.csrf().ignoringAntMatchers("/login", "/logout", "/unlock/apply");// 表单登录http.formLogin()// 登录页面.loginPage(loginUrl)// 登录处理url.loginProcessingUrl("/login");http.httpBasic();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.userDetailsService(userService).passwordEncoder(passwordEncoder());}/***  以内存的方式载入用户信息@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();auth.inMemoryAuthentication()   //直接创建一个静态用户.passwordEncoder(encoder).withUser("leixi").password(encoder.encode("123456")).roles("USER");}@Bean@Overridepublic UserDetailsService userDetailsServiceBean() throws Exception {return super.userDetailsServiceBean();}*/@Bean   //这里需要将AuthenticationManager注册为Bean,在OAuth配置中使用@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}//通过redis存储token@Beanpublic 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 {@Overridepublic 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 {@Overridepublic 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);}}@Overridepublic 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 {@Autowiredprivate MD5PasswordEncoder passwordEncoder;@Resourceprivate UserDetailsService userDetailService;@Autowiredprivate JdbcTemplate jdbcTemplate;@Autowiredprivate CustomTokenEnhancer customTokenEnhancer;private static final String loginUrl = "/loginpage.html";@Beanpublic RegisteredClientRepository registeredClientRepository() {JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);return jdbcRegisteredClientRepository;}/*** 在内存中获取用户信息的方式@Beanpublic UserDetailsService userDetailsService() {UserDetails userDetails = User.builder().username("leixi").roles("USER").password(passwordEncoder.encode("123456")).build();return new InMemoryUserDetailsManager(userDetails);}*//*** 在内存中获取客户端信息的方式,还可以用于客户端信息的入库*@Beanpublic 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);}*/@Beanpublic 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();}@Beanpublic DaoAuthenticationProvider daoAuthenticationProvider() {DaoAuthenticationProvider customerDaoAuthenticationProvider = new DaoAuthenticationProvider();// 设置userDetailsServicecustomerDaoAuthenticationProvider.setUserDetailsService(userDetailService);// 禁止隐藏用户未找到异常customerDaoAuthenticationProvider.setHideUserNotFoundExceptions(false);// 使用MD5进行密码的加密customerDaoAuthenticationProvider.setPasswordEncoder(passwordEncoder);return customerDaoAuthenticationProvider;}@Beanpublic 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();}@Beanpublic JwtEncoder jwtEncoder() {NimbusJwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource());return jwtEncoder;}@Beanpublic JwtDecoder jwtDecoder() {return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource());}@Beanpublic OAuth2TokenGenerator<?> tokenGenerator() {JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder());jwtGenerator.setJwtCustomizer(customTokenEnhancer);OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();return new DelegatingOAuth2TokenGenerator(jwtGenerator, accessTokenGenerator, refreshTokenGenerator);}@Beanpublic 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> {@Resourceprivate UserDetailsService userDetailService;@Overridepublic 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:offJwtClaimsSet.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:onreturn 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 {@Overridepublic 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;}@Overridepublic 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();//endregionString 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:offJwtEncodingContext 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:onthis.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;// 生成tokenOAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,jwtAccessToken.getTokenValue(), jwtAccessToken.getIssuedAt(),jwtAccessToken.getExpiresAt(), authorizedScopes);return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken);}@Overridepublic 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实现认证授权服务  (首推,我就是看他的博客才配好服务端客户端的。)

        新版本Spring Security 2.7 + 用法

        SpringSecurity最新学习,spring-security-oauth2-authorization-server

        Springboot2.7 OAuth2 server使用jdbc存储RegisteredClient

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/434387.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Ansible-template模块动态生成特定文件

文章目录 一、Jinja2介绍什么是主要特性安装基本用法进阶特性总结 Jinja2与Ansible关系1. 模板引擎2. Ansible 的依赖3. 变量和模板4. 动态生成配置5. 社区和生态系统总结 二、Ansible如何使用Jinja2使用template模块Jinja2文件中使用判断和循环Jinja2文件中使用判断语法 Jinja…

onload_tcpdump命令抓包报错Onload stack [7,] already has tcpdump process

最近碰到Onload 不支持同时运行多个 tcpdump 进程的报错&#xff0c;实际上使用了ps查询当时系统中并没有tcpdump相关进程存在。需要重启服务器本机使用onload加速的相关进程后才能使用onload_tcpdump正常抓包&#xff0c;很奇怪&#xff0c;之前确实没遇到这样的问题&#xff…

C++友元和运算符重载

目录 一. 友元 friend 1.1 概念 1.2 友元函数 1.3 友元类 1.4 友元成员函数 二. 运算符重载 2.1 概念 2.2成员函数运算符重载 2.3 成员函数运算符重载 2.4 特殊运算符重载 2.4.1 赋值运算符重载 2.4.2 类型转换运算符重载 2.5 注意事项 三、std::string 字符串类…

MyBatis-Plus分页查询

在实际开发中&#xff0c;对于大量数据的查询&#xff0c;可以通过分页查询的方式来减少查询量和提高查询效率。在 MyBatis-Plus 中&#xff0c;分页查询可以通过使用 Page 对象和 IService 接口提供的分页方法来实现。MyBatis-Plus 的分页插件 PaginationInnerInterceptor 提供…

基于Spring框架的分层解耦详解

博客主页&#xff1a;誓则盟约系列专栏&#xff1a;Java Web关注博主&#xff0c;后期持续更新系列文章如果有错误感谢请大家批评指出&#xff0c;及时修改感谢大家点赞&#x1f44d;收藏⭐评论✍ Java Web 三层架构&#xff1a; Java Web可以大致被分为三层架构&#xff1a;…

Win11 安装 PostgreSQL 数据库,两种方式详细步骤

文章目录 一、exe文件安装 &#xff08;推荐&#xff09;下载安装包1. 选择操作系统2. 跳转到EDB&#xff08;PostgreSQL 的安装包托管在 EDB上&#xff09;3. 选择版本点击下载按钮 安装1. 管理员打开安装包2. 选择安装目录3. 勾选安装项4. 设置数据存储目录5. 设置管理员密码…

【C++报错已解决】std::ios_base::floatfield

&#x1f3ac; 鸽芷咕&#xff1a;个人主页 &#x1f525; 个人专栏: 《C干货基地》《粉丝福利》 ⛺️生活的理想&#xff0c;就是为了理想的生活! 专栏介绍 在软件开发和日常使用中&#xff0c;BUG是不可避免的。本专栏致力于为广大开发者和技术爱好者提供一个关于BUG解决的经…

YOLOv8改进,YOLOv8主干网络替换为GhostNetV3(2024年华为提出的轻量化架构,全网首发),助力涨点

摘要 GhostNetV3 是由华为诺亚方舟实验室的团队发布的,于2024年4月发布。 摘要:紧凑型神经网络专为边缘设备上的应用设计,具备更快的推理速度,但性能相对适中。然而,紧凑型模型的训练策略目前借鉴自传统模型,这忽略了它们在模型容量上的差异,可能阻碍紧凑型模型的性能…

如何用ChatGPT制作一款手机游戏应用

有没有想过自己做一款手机游戏&#xff0c;并生成apk手机应用呢&#xff1f;有了人工智能&#xff0c;这一切就成为可能。今天&#xff0c;我们就使用ChatGPT来创建一个简单的井字棋游戏&#xff08;Tic-Tac-Toe&#xff09;&#xff0c;其实这个过程非常轻松且高效。 通过Cha…

从哪里下载高清解压视频素材?推荐五个优质素材资源网站

想制作吸引人的抖音小说推文&#xff0c;但不知道从哪里获取高清解压视频素材&#xff1f;今天就为大家推荐五个优秀的网站&#xff0c;帮助你轻松找到所需的素材&#xff0c;提升你的创作质量。 首先是蛙学网 作为国内顶级的短视频素材网站&#xff0c;蛙学网提供了丰富的4K高…

浅谈java异常[Exception]

一&#xff0e; 异常的定义 在《java编程思想》中这样定义 异常&#xff1a;阻止当前方法或作用域继续执行的问题。虽然java中有异常处理机制&#xff0c;但是要明确一点&#xff0c;决不应该用"正常"的态度来看待异常。绝对一点说异常就是某种意义上的错误&#xf…

SpringBoot使用validation进行自参数校验

一&#xff1a;介绍 在 SpringBoot 项目开发中&#xff0c;很多与数据库交互的参数需要校验数据正确性。很多小伙伴会把参数判断写进代码里&#xff0c;但是这种写法往往会有低可读性以及多处使用的时候&#xff0c;需要变更验证规则时&#xff0c;不易于维护等缺点。今天给大家…

Java之多态

文章目录 1. 多态1.1 多态的概念 2. 方法的重写3. 向上转型3.13.2 发生向上转型的时机 4. 动态绑定和静态绑定5. 什么是多态5.15.2 多态的优缺点 6. 避免在构造方法中调用重写的方法7. 向下转型![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/fd1fa83140d94f37ab3b88…

HTML+CSS 水滴登录页

文章目录 一、效果演示二、Code1.HTML2.CSS 三、实现思路拆分 一、效果演示 实现了一个水滴登录页的效果。页面包含一个水滴形状的登录框和两个按钮&#xff0c;登录框包括用户名、密码和登录按钮&#xff0c;按钮分别为忘记密码和注册。整个页面的设计非常有创意&#xff0c;采…

一些超好用的 GitHub 插件和技巧

聊聊我平时使用 GitHub 时学到的一些插件、技巧。 ‍ ‍ 浏览器插件 在我的另一篇博客 浏览器插件推荐 里提到过跟 GitHub 相关的一些插件&#xff0c;这里重复下&#xff1a; Sourcegraph&#xff1a;在线打开项目&#xff0c;方便阅读&#xff0c;将 GitHub 变得和 IDE …

【AI创作组】Matlab中进行符号计算

提示:代码一定要自己运行过才算数…… 1. 符号计算工具箱介绍 1.1 工具箱功能 MATLAB的符号计算工具箱,即Symbolic Math Toolbox,是一套强大的数学软件工具,它使得MATLAB具备了符号运算的能力。该工具箱提供了一系列函数,用于求解、绘制和操作符号数学方程。用户可以直接…

【Linux】修改用户名用户家目录

0、锁定旧用户登录 如果旧用户olduser正在运行中是无法操作的&#xff0c;需要先禁用用户登录&#xff0c;然后杀掉所有此用户的进程。 1. 使用 usermod 命令禁用用户 这将锁定用户账户&#xff0c;使其无法登录&#xff1a; sudo usermod -L olduser2. 停止用户的进程 如…

Woocommerce怎么分类显示产品?如何将Shopify的产品导入到Woocommerce?

WooCommerce作为WordPress的一个电子商务插件&#xff0c;功能强大、使用简洁&#xff0c;能够轻松集成到WordPress网站中&#xff0c;为用户提供了一个完整的在线商店解决方案&#xff0c;在国外还是挺受欢迎的。 Woocommerce怎么分类显示产品&#xff1f; 在Woocommerce中&a…

【微服务】springboot 实现动态修改接口返回值

目录 一、前言 二、动态修改接口返回结果实现方案总结 2.1 使用反射动态修改返回结果参数 2.1.1 认识反射 2.1.2 反射的作用 2.1.3 反射相关的类 2.1.4 反射实现接口参数动态修改实现思路 2.2 使用ControllerAdvice 注解动态修改返回结果参数​​​​​​​ 2.2.1 注解…

docker pull 超时的问题如何解决

docker不能使用&#xff0c;使用之前的阿里云镜像失败。。。 搜了各种解决方法&#xff0c;感谢B站UP主 <iframe src"//player.bilibili.com/player.html?isOutsidetrue&aid113173361331402&bvidBV1KstBeEEQR&cid25942297878&p1" scrolling"…