SpringBoot3集成Oauth2.1——7数据库存储用户信息
增加加密方式
在之前的客户端中,SpringBoot3集成Oauth2.1——6数据库存储客户端信息,我们遇到一个密码自动升级的问题。
这里我们为了后面用户也存储到数据库,进行加密时,做一个改动
在SecurityConfig中,添加加密方式。
/** @Description: 加密方式* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午2:02:44*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
保存客户端信息时,进行加密。
@Autowiredprivate PasswordEncoder bcryptPasswordEncoder;entity.setClientSecret(bcryptPasswordEncoder.encode(entity.getClientSecret()));
此时,直接保存到数据库的密文,就没有前缀了。
备注:这样做的目的是,避免客户端自动升级的加密和用户名的加密存在冲突,导致登录认证时,解密失败。
定制UserDetails
为了方便,这里isAccountNonExpired、isAccountNonLocked、isCredentialsNonExpired、isEnabled,这里我都是返回true;
import java.util.Collection;
import java.util.List;import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;import lombok.Data;/** @Description: 定制:UserDetails* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午1:56:21*/
@Data
public class CustomUserDetail implements UserDetails {private static final long serialVersionUID = 1L;private String username;//这里千万不能写成userNameprivate String password;//这里千万不能写成passWordprivate boolean enabled;private List<String> authorityList;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return null;}@Overridepublic String getPassword() {return password;}@Overridepublic String getUsername() {return username;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return enabled;}
}
如果isEnabled返回false,则登录时,就会提示,用户已失效。这里的值你可以在:loadUserByUsername方法中去决定返回true,还是false。
定制UserDetailsService
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;import com.example.demo.module.user.entity.SysUser;
import com.example.demo.module.user.service.ISysUserService;@Service
public class CustomUserDetailsService implements UserDetailsService {@Autowiredprivate ISysUserService sysUserService;/** @Description: 根据用户加载用户* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午2:00:48*/@Override@Overridepublic UserDetails loadUserByUsername(String userName) {Map<String, Object> map = new HashMap<>();map.put("user_name", userName);//根据登录的用户名,从数据库查询用户(这里你用任意数据库框架都行,主要是查询出你数据库存储的密文密码,给框架去验证)List<SysUser> listByMap = sysUserService.listByMap(map);if(listByMap.isEmpty()) {throw new UsernameNotFoundException("用户不存在或用户密码错误:"+userName);}SysUser sysUser = listByMap.get(0);CustomUserDetail user = new CustomUserDetail();//设置用户名和用户密码密文,框架会自动去验证密码是否正确:等价于new BCryptPasswordEncoder().matches("明文密码","密文密码");user.setUsername(sysUser.getUserName());user.setPassword(sysUser.getPassWord());user.setEnabled(true);//权限暂时写死List<String> authoritys = new ArrayList<>();authoritys.add("admin");user.setAuthorityList(authoritys);return user;}
}
配置UserDetailsService
在SecurityConfig的认证授权配置下,添加.userDetailsService(customUserDetailsService)配置
public class SecurityConfig {//其他不变 @Autowiredprivate CustomUserDetailsService customUserDetailsService;/** @Description: 配置授权服务器(用于登录操作)* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月23日 下午3:15:35*/@Bean@Order(1)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =OAuth2AuthorizationServerConfigurer.authorizationServer();http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()).with(authorizationServerConfigurer, (authorizationServer) ->authorizationServer.oidc(Customizer.withDefaults())).authorizeHttpRequests((authorize) ->authorize.anyRequest().authenticated()).exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))// 设置自定义 UserDetailsService.userDetailsService(customUserDetailsService);return http.build();}
}
Json序列化白名单
默认情况下,Spring Security 只允许反序列化白名单中的类(如内置的 User 类),自定义的 UserDetails 实现类(如 CustomUserDetail)需要显式配置允许反序列化,否则会抛出该异常。
备注:老版本的oauth2.0,没有这个限制,但是和热部署存在冲突,不知道是不是那会儿就埋下坑了,如下代码所示,如果有热部署,就会序列化失败,如果没有热部署,就不会序列化失败(大概是5年前,反正最后我也没解决)
if(principal != null && principal instanceof CustomUserDetail) {CustomUserDetail user = (CustomUserDetail)principal;
}
The class with com.example.demo.config.oauth2.custom.CustomUserDetail and name of com.example.demo.config.oauth2.custom.CustomUserDetail is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details
这个报错:官方有问题单: https://github.com/spring-projects/spring-security/issues/4370
这里,我们在自定义的CustomUserDetail加上忽略字段映射和白名单。
/** @Description: 定制:UserDetails* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午1:56:21*/
@Data
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
@JsonIgnoreProperties(ignoreUnknown = true)
public class CustomUserDetail implements UserDetails {//其他不变。
}
如果不加:@JsonIgnoreProperties(ignoreUnknown = true),会报如下错,
Unrecognized field “accountNonExpired” (class com.example.demo.config.oauth2.custom.CustomUserDetail), not marked as ignorable (5 known properties: “enabled”, “passWord”, “userName”, “authorityList”, “authorities”])
at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: com.example.demo.config.oauth2.custom.CustomUserDetail[“accountNonExpired”])
原因是
Jackson 的属性映射规则:
Jackson 会通过方法名推断属性名。例如:
isAccountNonExpired() → 推断属性名为 accountNonExpired
isEnabled() → 推断属性名为 enabled(你的类中已声明 enabled 属性,所以不报错)。
由于 CustomUserDetail 未定义 accountNonExpired 等属性,Jackson 反序列化时会抛出 “未识别字段” 的错误。
完整代码
保存客户端信息
@RestController
@Tag(name = "API-OAuth 2.1 客户端注册信息表")
@RequestMapping("/oauth2RegisteredClient")
public class Oauth2RegisteredClientController {@Autowiredprivate IOauth2RegisteredClientService oauth2RegisteredClientService;@Autowiredprivate PasswordEncoder bcryptPasswordEncoder;/*** @description:新增OAuth 2.1 客户端注册信息表* @author hutao* @throws JsonProcessingException * @date 2025-05-26*/@Operation(summary = "1新增OAuth 2.1 客户端注册信息表")@PostMapping("/info/save")@ApiOperationSupport(order = 1)public R<String> saveOauth2RegisteredClientInfo(@RequestBody Oauth2RegisteredClient entity) throws JsonProcessingException {ObjectMapper objectMapper = new ObjectMapper();//支持 Java8日期模块 ObjectMapper 并注册 JSR-310 模块objectMapper.registerModule(new JavaTimeModule());//添加@class支持objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),ObjectMapper.DefaultTyping.NON_FINAL,JsonTypeInfo.As.PROPERTY);//构建 clientSettingsClientSettings clientSettings = ClientSettings.builder().requireAuthorizationConsent(true).build();//构建 TokenSettingsTokenSettings tokenSettings = TokenSettings.builder().accessTokenTimeToLive(Duration.ofMinutes(30)) // 访问令牌有效期.refreshTokenTimeToLive(Duration.ofDays(7)) // 刷新令牌有效期.reuseRefreshTokens(true) // 允许复用刷新令牌.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)// ID令牌签名算法.build();entity.setClientSettings(objectMapper.writeValueAsString(clientSettings.getSettings()));entity.setTokenSettings(objectMapper.writeValueAsString(tokenSettings.getSettings()));entity.setClientSecret(bcryptPasswordEncoder.encode(entity.getClientSecret()));oauth2RegisteredClientService.save(entity);return R.ok();}
}
定制:UserDetails
import java.util.Collection;
import java.util.List;import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonTypeInfo;import lombok.Data;/** @Description: 定制:UserDetails* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午1:56:21*/
@Data
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
@JsonIgnoreProperties(ignoreUnknown = true)
public class CustomUserDetail implements UserDetails {private static final long serialVersionUID = 1L;private String username;private String password;private boolean enabled;private List<String> authorityList;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return null;}@Overridepublic String getPassword() {return password;}@Overridepublic String getUsername() {return username;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return enabled;}
}
定制:UserDetailsService
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;import com.example.demo.module.user.entity.SysUser;
import com.example.demo.module.user.service.ISysUserService;@Service
public class CustomUserDetailsService implements UserDetailsService {@Autowiredprivate ISysUserService sysUserService;/** @Description: 根据用户加载用户* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午2:00:48*/@Overridepublic UserDetails loadUserByUsername(String userName) {Map<String, Object> map = new HashMap<>();map.put("user_name", userName);//根据登录的用户名,查询用户List<SysUser> listByMap = sysUserService.listByMap(map);if(listByMap.isEmpty()) {throw new UsernameNotFoundException("用户不存在或用户密码错误:"+userName);}SysUser sysUser = listByMap.get(0);CustomUserDetail user = new CustomUserDetail();//设置用户名和用户密码密文,框架会自动去验证密码是否正确:等价于new BCryptPasswordEncoder().matches("明文密码","密文密码");user.setUsername(sysUser.getUserName());user.setPassnord(sysUser.getPassWord());user.setEnabled(true);//权限暂时写死List<String> authoritys = new ArrayList<>();authoritys.add("admin");user.setAuthorityList(authoritys);return user;}
}
SecurityConfig
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.List;
import java.util.Map;
import java.util.UUID;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.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.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.context.WebApplicationContext;import com.example.demo.UrlsUtils;
import com.example.demo.config.oauth2.custom.CustomUserDetailsService;
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 lombok.extern.log4j.Log4j2;@Configuration
@EnableWebSecurity
@Log4j2
public class SecurityConfig {@Autowiredprivate WebApplicationContext applicationContext;@Autowiredprivate CustomUserDetailsService customUserDetailsService;@Value(value = "${ignore-url}")private String ignoreUrl;@Value(value = "${package.path}")private String packagePath;/** @Description: 配置授权服务器(用于登录操作)* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月23日 下午3:15:35*/@Bean@Order(1)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =OAuth2AuthorizationServerConfigurer.authorizationServer();http.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()).with(authorizationServerConfigurer, (authorizationServer) ->authorizationServer.oidc(Customizer.withDefaults())).authorizeHttpRequests((authorize) ->authorize.anyRequest().authenticated()).exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"),new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))// 设置自定义 UserDetailsService.userDetailsService(customUserDetailsService);return http.build();}/** @Description: 配置需要保护的资源* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月23日 下午2:28:20*/@Bean@Order(2)public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception {//获取所有的接口地址Map<String, Object> filteredControllers = UrlsUtils.getControllerClass(packagePath, applicationContext);// 使用过滤后的控制器获取 URLList<String> allUrl = UrlsUtils.getControllerUrls(filteredControllers);String[] split = ignoreUrl.split(";");for (String url : split) {url = url.replaceAll(" ", "");UrlsUtils.removeUrl(allUrl, url);UrlsUtils.removeUrl(allUrl, url);}log.info("受保护的API地址:{}",allUrl);http.securityMatcher(allUrl.toArray(new String[0])).csrf(csrf -> csrf.disable()).authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())).formLogin(form -> form.disable());return http.build();}/** @Description: 配置不需要保护的资源* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月23日 下午2:28:20*/@Bean@Order(3)public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {String[] split = ignoreUrl.split(";");for (int i = 0; i < split.length; i++) {split[i] = split[i].replaceAll(" ", "");}log.info("公共开放的API地址:{}",Arrays.asList(split));http.csrf(csrf -> csrf.ignoringRequestMatchers(split)).authorizeHttpRequests(authorize ->authorize.requestMatchers(split).permitAll().anyRequest().authenticated()).formLogin(Customizer.withDefaults()).logout(Customizer.withDefaults());return http.build();}/*@Beanpublic UserDetailsService userDetailsService() {UserDetails userDetails = User.withDefaultPasswordEncoder().username("hutao").password("2025.com").roles("USER").build();return new InMemoryUserDetailsManager(userDetails);}*/@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);}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;}@Beanpublic JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);}@Beanpublic AuthorizationServerSettings authorizationServerSettings() {return AuthorizationServerSettings.builder().build();}/** @Description: 客户端信息:对应表:oauth2_registered_client* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 上午10:50:52*/@Beanpublic RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {return new JdbcRegisteredClientRepository(jdbcTemplate);}/** @Description: 授权信息:对应表:oauth2_authorization* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 上午10:51:10*/@Beanpublic OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository);}/** @Description: 授权确认:对应表:oauth2_authorization_consent* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 上午10:51:29*/@Beanpublic OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) {return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository);}/** @Description: 加密方式* @author: 胡涛* @mail: hutao_2017@aliyun.com* @date: 2025年5月26日 下午2:02:44*/@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}public static void main(String[] args) {//{bcrypt}$2a$10$yiIGkkSaBH5.QekQXoPv/efS.1b8YZsxLpyXs8HYo9Cdqy8cbDPPiboolean matches = new BCryptPasswordEncoder().matches("secret", "$2a$10$yiIGkkSaBH5.QekQXoPv/efS.1b8YZsxLpyXs8HYo9Cdqy8cbDPPi");System.out.println(matches);}}
测试验证
注意事项
org.springframework.security.core.userdetails.UserDetails,以我这个版本为例(SpringBoot3.4.5/oauth2-authorization-server1.4.3)
我们定制的CustomUserDetail一定要和UserDetails对应上。
不然输入账号密码登录认证不会报错,但是登录以后使用code码换token,则会报错,而报错原因就是没有用户信息为null,从堆栈跟踪看,错误发生在JwtGenerator.generate方法,具体是JwtClaimsSet.Builder.subject时传入了null。
java.lang.IllegalArgumentException: value cannot be nullat org.springframework.util.Assert.notNull(Assert.java:181) ~[spring-core-6.2.6.jar:6.2.6]at org.springframework.security.oauth2.jwt.JwtClaimsSet$Builder.claim(JwtClaimsSet.java:167) ~[spring-security-oauth2-jose-6.4.5.jar:6.4.5]at org.springframework.security.oauth2.jwt.JwtClaimsSet$Builder.subject(JwtClaimsSet.java:104) ~[spring-security-oauth2-jose-6.4.5.jar:6.4.5]