Spring Cloud实战 | 第六篇:Spring Cloud Gateway+Spring Security OAuth2+JWT实现微服务统一认证授权
1、ljf-admin添加数据库表和数据
CREATE TABLE oauth_client_details ( client_id varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL, resource_ids varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, client_secret varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, scope varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, authorized_grant_types varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, web_server_redirect_uri varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, authorities varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, access_token_validity int(11) NULL DEFAULT NULL, refresh_token_validity int(11) NULL DEFAULT NULL, additional_information varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, autoapprove varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, PRIMARY KEY (client_id) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
INSERT INTO oauth_client_details VALUES ('ljf-admin', NULL, '123456', 'all', 'password,refresh_token', '', NULL, NULL, NULL, NULL, NULL);
2、生成CRUD
3、ljf-admin模块提供client查询接口
@RestController
@RequestMapping("/api/v1/Oauth-client")
public class OauthController {
@Autowired
private OauthClientDetailsService oauthClientDetailsService;
@GetMapping("/{clientId}")
public Result detail(@PathVariable String clientId) {
OauthClientDetails client = oauthClientDetailsService.selectByPrimaryKey(clientId);
return Result.success(client);
}
}
4、ljf-admin-api提供feign接口
@FeignClient(value = "ljf-admin")
public interface OAuthClientFeign {
@GetMapping("/api/v1/Oauth-client/{clientId}")
Result getOAuthClientById(@PathVariable(value = "clientId") String clientId);
}
5、ljf-auth服务测试访问feign接口
6、ljf-auth添加依赖
org.springframework.cloud
spring-cloud-starter-oauth2
org.springframework.security
spring-security-oauth2-jose
7、安全拦截配置
/**
* 安全配置主要是配置请求访问权限、定义认证管理器、密码加密配置
*/
@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* http安全配置
*
* @param http http安全对象
* @throws Exception http安全异常信息
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/oauth/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.csrf()
.disable();
}
/**
* 如果不配置SpringBoot会自动配置一个AuthenticationManager,覆盖掉内存中的用户
*/
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
8、认证授权配置
/**
* @Auther: lijinfeng
* @Date: 2021/12/8
* @Description 描述:认证服务配置
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private ClientDetailsServiceImpl clientDetailsService;
// 认证管理器 WebSecurityConfig 中创建bean
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsServiceImpl userDetailsService;
/**
* 客户端信息配置:client存储方式
*/
@Override
@SneakyThrows
public void configure(ClientDetailsServiceConfigurer clients) {
System.out.println("ljf-auth:AuthorizationServerConfig::configure::OAuth2客户端【数据库加载】");
clients.withClientDetails(clientDetailsService);
}
/**
* 配置授权(authorization)
* 以及令牌(token)的访问端点和令牌服务(token services)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
System.out.println("ljf-auth:配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)");
//新建TokenEnhancerChain类,为了整和token增强的多个配置,一次性注入endpoints中
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
List tokenEnhancers = new ArrayList<>();
tokenEnhancers.add(tokenEnhancer()); //对token内容进行增强
tokenEnhancers.add(jwtAccessTokenConverter()); //token使用非对称加密算法
tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);
endpoints
.authenticationManager(authenticationManager)
.accessTokenConverter(jwtAccessTokenConverter())
.tokenEnhancer(tokenEnhancerChain) //将令牌增强器注入endpoints中
.userDetailsService(userDetailsService) //用户认证
// refresh token有两种使用方式:重复使用(true)、非重复使用(false),默认为true
// 1 重复使用:access token过期刷新时, refresh token过期时间未改变,仍以初次生成的时间为准
// 2 非重复使用:access token过期刷新时, refresh token过期时间延续,在refresh token有效期内刷新便永不失效达到无需再次登录的目的
.reuseRefreshTokens(true);
}
/**
* 使用非对称加密算法对token签名
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
System.out.println("从classpath下的密钥库中获取密钥对(公钥+私钥)");
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("ljf.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair("ljf", "123456".toCharArray());
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyPair);
return converter;
}
/**
* JWT内容增强
*/
@Bean
public TokenEnhancer tokenEnhancer() {
return (accessToken, authentication) -> {
// 获取登录用户信息
OAuthUserDetails OAuthUserDetails = (OAuthUserDetails) authentication.getUserAuthentication().getPrincipal();
// 将用户信息放到accessToken中返回
Map additionalInfo = CollectionUtil.newHashMap();
additionalInfo.put("userId", OAuthUserDetails.getId());
additionalInfo.put("username", OAuthUserDetails.getUsername());
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
};
}
/**
* 从classpath下的密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
System.out.println("从classpath下的密钥库中获取密钥对(公钥+私钥)");
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("ljf.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair("ljf", "123456".toCharArray());
return keyPair;
}
/**
* 允许表单认证
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.allowFormAuthenticationForClients();
}
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setHideUserNotFoundExceptions(false); // 用户不存在异常抛出
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
/**
* 密码编码器
* 注意:想要密码编码器生效,需要authenticationProvider()该方法注入
*
* 委托方式,根据密码的前缀选择对应的encoder,例如:{bcypt}前缀->标识BCYPT算法加密;{noop}->标识不使用任何加密即明文的方式
* 密码判读 DaoAuthenticationProvider#additionalAuthenticationChecks
*
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
认证授权配置类主要实现功能:
- 指定构建用户认证信息UserDetailsService为UserDetailsServiceImpl,从数据库获取用户信息和前端传值进行密码判读
- 指定构建客户端认证信息ClientDetailsService为ClientDetailsServiceImpl,从数据库获取客户端信息和前端传值进行密码判读
- JWT加签,从密钥库获取密钥对完成对JWT的签名,密钥库生成
- JWT增强
9、 UserDetailService自定义实现加载用户认证信息
/**
* 【重要】从数据库获取用户信息,用于和前端传过来的用户信息进行密码判读
*/
@Service
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserAdminFeign userAdminFeign;
@Autowired
private OAuthClientFeign oAuthClientFeign;
// @Autowired
// private MemberFeignClient memberFeignClient;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
String clientId = JwtUtils.getAuthClientId();
Result result;
OAuthUserDetails oauthUserDetails = null;
OauthClientDetails oauthClientDetails = oAuthClientFeign.getOAuthClientById(clientId).getData();
if (!ObjectUtils.isEmpty(oauthClientDetails)) {
result = userAdminFeign.getUserByUsername(username);
System.out.println("ljf-auth:-----------------result.toString():"+result.toString());
if (ResultEnum.SUCCESS.getCode().equals(result.getCode())) {
SysUser sysUser = result.getData();
System.out.println("ljf-auth:-----------------sysUser.toString():"+sysUser.toString());
//TODO: 完善SysUser对象装换为UserDTO对象,实现角色权限的注入
oauthUserDetails = new OAuthUserDetails(sysUser);
System.out.println("ljf-auth:-----------------oauthUserDetails.toString():"+oauthUserDetails.toString());
}
} else {
throw new NoSuchClientException("该clientId不存在: " + clientId);
}
if (oauthUserDetails == null || oauthUserDetails.getId() == null) {
throw new UsernameNotFoundException(ResultEnum.USER_NO_EXIST.getMessage());
} else if (!oauthUserDetails.isEnabled()) {
throw new DisabledException("该账户已被禁用!");
} else if (!oauthUserDetails.isAccountNonLocked()) {
throw new LockedException("该账号已被锁定!");
} else if (!oauthUserDetails.isAccountNonExpired()) {
throw new AccountExpiredException("该账号已过期!");
}
return oauthUserDetails;
}
}
10、 ClientDetailsService自定义加载客户端认证信息
/**
* 在配置类 AuthorizationServerConfig 中使用
*
* 重写loadClientByClientId方法,从而达到自定义封装client信息的目的
*
* 从数据库获取client信息
*/
@Service
public class ClientDetailsServiceImpl implements ClientDetailsService {
@Autowired
private OAuthClientFeign oAuthClientFeign;
@Override
@SneakyThrows
public ClientDetails loadClientByClientId(String clientId) {
try {
Result result = oAuthClientFeign.getOAuthClientById(clientId);
// System.out.println("ljf-auth:loadClientByClientId::result"+result.toString());
if (Result.success().getCode().equals(result.getCode())) {
// System.out.println("ljf-auth:loadClientByClientId::result"+result.getData());
OauthClientDetails client = result.getData();
BaseClientDetails clientDetails = new BaseClientDetails(
client.getClientId(),
client.getResourceIds(),
client.getScope(),
client.getAuthorizedGrantTypes(),
client.getAuthorities(),
client.getWebServerRedirectUri());
clientDetails.setClientSecret(PasswordEncoderTypeEnum.NOOP.getPrefix() + client.getClientSecret());
return clientDetails;
} else {
throw new NoSuchClientException("该clientId不存在: " + clientId);
}
} catch (EmptyResultDataAccessException var4) {
throw new NoSuchClientException("该clientId不存在: " + clientId);
}
}
}
11、测试是否可以正常获取token
12、配置OAuth2资源服务器 即 ljf-gateway
OAuth2资源服务器是提供给客户端资源的服务器,有验证token的能力,token有效则放开资源,对应【有来项目】的youlai-gateway网关。
pom依赖
org.springframework.security
spring-security-config
org.springframework.security
spring-security-oauth2-resource-server
org.springframework.security
spring-security-oauth2-jose
配置ResourceServerConfig资源服务器配置 网关层的安全配置
/**
* @Auther: lijinfeng
* @Date: 2021/12/13
* @Description 描述: 资源服务器配置 网关层的安全配置
*/
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
//jwt认证
http.oauth2ResourceServer()
.jwt()
.publicKey(rsaPublicKey());// 本地获取公钥
http.csrf().disable();
//处理异常
http.exceptionHandling()
.accessDeniedHandler(accessDeniedHandler()) // 处理未授权自定义响应
.authenticationEntryPoint(authenticationEntryPoint());//处理未认证自定义响应
//这段配置表示除pathMatchers外其它请求都必须是认证(登陆成功)之后才可以访问。
http.authorizeExchange()
.pathMatchers("/ljf-auth/oauth/token/**")
.permitAll()
.anyExchange()
.authenticated();
return http.build();
}
/**
* 本地获取JWT验签公钥
*
* @return
*/
@SneakyThrows
@Bean
public RSAPublicKey rsaPublicKey() {
Resource resource = new ClassPathResource("public.key");
InputStream is = resource.getInputStream();
String publicKeyData = IoUtil.read(is).toString();
X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
RSAPublicKey rsaPublicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);
return rsaPublicKey;
}
/**
* @return
* @link https://blog.csdn.net/qq_24230139/article/details/105091273
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
* 需要把jwt的Claim中的authorities加入
* 方案:重新定义权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
/**
* 未授权自定义响应
*
* @return
*/
@Bean
ServerAccessDeniedHandler accessDeniedHandler() {
return (exchange, denied) -> {
Mono mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
.flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultEnum.ACCESS_UNAUTHORIZED));
return mono;
};
}
/**
* token无效或者已过期自定义响应
*/
@Bean
ServerAuthenticationEntryPoint authenticationEntryPoint() {
return (exchange, e) -> {
Mono mono = Mono.defer(() -> Mono.just(exchange.getResponse()))
.flatMap(response -> ResponseUtils.writeErrorInfo(response, ResultEnum.TOKEN_INVALID_OR_EXPIRED));
return mono;
};
}
}
添加工具类
/**
* 封装http返回响应对象
*/
public class ResponseUtils {
public static Mono writeErrorInfo(ServerHttpResponse response, ResultEnum resultCode){
response.setStatusCode(HttpStatus.OK);
response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.getHeaders().set("Access-Control-Allow-Origin", "*");
response.getHeaders().set("Cache-Control", "no-cache");
String body = JSONUtil.toJsonStr(Result.error(resultCode));
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(Charset.forName("UTF-8")));
return response.writeWith(Mono.just(buffer))
.doOnError(error -> DataBufferUtils.release(buffer));
}
}