在微服务工程中,Nacos作为目前主流的注册中心和配置中心,Spring Cloud Gateway作为目前主流的网关,下面引入spring security+oauth2+jwt作为认证和授权中心,进行简单的聊聊。
一、spring security
Spring Security是java中的安全框架,主要包括认证和授权,通俗讲,认证就是登录,授权就是权限鉴别。
安全这一块从来都有说不完的话题,一个简单的注册登录很好做,但是你要是考虑到各种各样的攻击,XSS、CSRF 等等,一个简单的注册登录也能做的很复杂。
幸运的是,即使你对各种攻击不太熟悉,只要你用了 Spring Security,就能自动避免掉很多攻击了,因为 Spring Security 已经自动帮我们完成很多防护了。
具体底层原理和流程请参考官网或大佬解读文章。
二、oauth2
OAuth是一个开放标准,该标准允许用户让第三方应用访问用户在某一应用上存储的私密资源(如头像),而在这个过程无需将用户名和密码提供给第三方应用。实现这一功能是通过提供一个令牌token,而不是用户名和密码来访问应用上的数据。
OAuth2是OAuth协议的下一版本,但不向下兼容OAuth。
实际生活中场景,如小区的业主点了一份外卖,但是小区的门禁系统不让外面小哥进入,此时要想外卖小哥进入,只能业主自己下来开门或告知门禁密码。若告知门禁密码,那外卖小哥岂不是可以随意进出小区了,这明显造成了安全隐患。于是就出现了一个授权机制:
1、门禁系统增加一个授权按钮,外卖小哥只需要点击授权按钮呼叫业主
2、业主收到外卖小哥呼叫,做出应答授权
3、门禁系统弹窗一个令牌,有效期30min,在有效期内可以凭借令牌进行小区
4、外卖小哥输入令牌进入小区
令牌和密码的区别:
1、时效不同,令牌是有过期时间的,而密码一般是永久的;
2、权限不同,令牌的权限的有限的,而密码一般是所有权限的;
3、令牌可以撤销,令牌可以撤销,失效就不能用了 ,而密码一般不允许撤销;
在java开发中,OAuth2的出现就是为了解决类似上面场景的问题。
OAuth2协议提供了4种不同的授权模式:
1、授权码模式,常见的第三方平台登录功能基本都是使用这种模式;
2、简化模式,不需要客户端和服务器参与,直接在浏览器种向授权服务器申请令牌token;
3、密码模式,用户把用户名和密码直接告诉客户端,客户端使用这些信息向授权服务器申请令牌token;如客户端应用和服务提供商是同一家公司,自己做前后端分离登录就可以采用这种模式;
4、客户端模式,客户端使用自己的名誉而不是用户的名义向服务提供者申请授权,严格来说,客户端模式并不能算作OAuth协议要解决问题的一种解决方案;
具体模式的授权过程请参考官网或大佬文章。
三、统一认证和鉴权架构
微服务的认证授权方案,目前大体分为两类:
1、网关只负责转发请求,认证鉴权交给每个微服务控制
2、统一在网关层面进行认证鉴权,微服务只负责业务
目前主流采用第二种方案,通过Spring Cloud Gateway + Spring Security + OAuth2 + Jwt整合进行统一认证鉴权,大致流程如下
大致分为四个角色,如下:
1、客户端,需要访问微服务资源
2、网关服务,负责转发、认证、鉴权
3、认证授权服务,负责认证授权颁发令牌
4、微服务,提供具体的业务服务
针对上述架构需要3个服务,分别如下:
1、工程前缀-auth,认证授权服务:负责认证和授权;
2、工程前缀-gateway,网关服务:负责校验认证和鉴权;
3、工程前缀-order,订单微服务:处理业务逻辑;
基础是采用Nacos作为注册与发现中心,安全相关的逻辑只存在于认证服务和网关服务中,其他微服务只是单纯的提供服务而没有任何安全相关逻辑。
通过认证授权服务进行统一的认证和授权,然后通过网关服务来统一校验认证和鉴权,此方案目前为主流方案。
四、工程前缀-auth认证授权服务
1、pom依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>8.16</version>
</dependency>
2、bootstrap-dev.yml配置
spring:
main:
allow-circular-references: true
mvc:
pathmatch:
matching-strategy: ant_path_matcher
cloud:
nacos:
# 注册中心
discovery:
server-addr: http://localhost:8848
# 配置中心
config:
# 本地启动
## server-addr: ${spring.cloud.nacos.discovery.server-addr}
server-addr: http://localhost:8848
file-extension: yaml
#prefix 默认为 spring.application.name 的值
# 公共配置
shared-configs[0]:
data-id: youlai-common.yaml
refresh: true
3、在jdk的bin目录下使用如下命令生成RSA证书jwt.jks,复制到resource目录下
keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
4、认证授权配置
/**
* 配置令牌端点(Token Endpoint)的安全约束
* @param security
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
// 自定义异常处理端口
security.authenticationEntryPoint(customAuthenticationEntryPoint);
security.accessDeniedHandler(customAccessDeniedHandler);
security
// oauth/token_key
.tokenKeyAccess("permitAll()")
// oauth/check_token
.checkTokenAccess("isAuthenticated()")
// 允许客户表单认证
.allowFormAuthenticationForClients();
}
/**
* 配置OAuth2客户端
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(customClientDetailsService);
}
/**
* 配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)
* @param endpoints
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
// 密码模式下配置认证管理器AuthenticationManager
endpoints.authenticationManager(authenticationManager);
// 设置AccessToken的存储介质tokenStore,默认使用内存当做存储介质
endpoints.tokenStore(tokenStore);
// 设置JwtAccessToken转换器
endpoints.accessTokenConverter(jwtAccessTokenConverter);
// 自定义token生成
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter, customTokenEnhancer));
// 配置TokenService参数
DefaultTokenServices tokenServices = new DefaultTokenServices();
// token持久化容器
tokenServices.setTokenStore(endpoints.getTokenStore());
// 是否支持refresh_token,默认false
tokenServices.setSupportRefreshToken(true);
// 客户端信息
tokenServices.setClientDetailsService(endpoints.getClientDetailsService());
// token增强
tokenServices.setTokenEnhancer(tokenEnhancerChain);
// access_token 的有效时长 (秒), 默认 12 小时;1小时
tokenServices.setAccessTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(1));
// refresh_token 的有效时长 (秒), 默认 30 天;1小时
tokenServices.setRefreshTokenValiditySeconds((int) TimeUnit.HOURS.toSeconds(1));
// 是否复用refresh_token,默认为true(如果为false,则每次请求刷新都会删除旧的refresh_token,创建新的refresh_token)
tokenServices.setReuseRefreshToken(false);
// token相关服务
endpoints.tokenServices(tokenServices);
// 放入自定义授权模式,先把默认的几个模式放进去,最后加入我们自定义模式
List<TokenGranter> grantersList = new ArrayList<>(Arrays.asList(endpoints.getTokenGranter()));
grantersList.add(new CustomCodeGranter(endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), authenticationManager));
endpoints.tokenGranter(new CompositeTokenGranter(grantersList));
}
5、自定义jwt口令
@Configuration
public class CustomTokenConfig {
private String SIGNING_KEY = "singing";
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
//JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
JwtAccessTokenConverter converter = new CustomJwtAccessTokenConverter();
// 设置对称密钥
//converter.setSigningKey(SIGNING_KEY);
// 设置非对称密钥
converter.setKeyPair(keyPair());
return converter;
}
/**
* 密钥库中获取密钥对(公钥+私钥)
*/
@Bean
public KeyPair keyPair() {
KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
KeyPair keyPair = factory.getKeyPair("jwt", "123456".toCharArray());
return keyPair;
}
}
由于我们的网关服务需要RSA的公钥来验证签名是否合法,所以认证服务需要有个接口把公钥暴露出来:
@RestController
@RequiredArgsConstructor
public class KeyPairController {
private final KeyPair keyPair;
@GetMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
}
6、Spring Security,允许获取公钥接口的访问
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 配置Spring Security中的过滤器链
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().disable();
http.csrf().disable();
http
.authorizeRequests().antMatchers("/oauth/**", "/rsa/publicKey").permitAll()
.and()
.authorizeRequests().anyRequest().authenticated();
http
.sessionManagement()
.invalidSessionUrl("/login")
.maximumSessions(1)
.expiredUrl("/login");
}
五、工程前缀-gateway网关服务
1、pom
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.23</version>
</dependency>
2、bootstrap-dev.yml配置
spring:
main:
web-application-type: reactive
cloud:
nacos:
# 注册中心
discovery:
server-addr: http://localhost:8848
# 配置中心
config:
# 本地启动
## server-addr: ${spring.cloud.nacos.discovery.server-addr}
server-addr: http://localhost:8848
file-extension: yaml
shared-configs[0]:
data-id: youlai-common.yaml
refresh: true
gateway:
discovery:
locator:
# 使用服务发现路由
enabled: true
# 设置路由id 理论上随便写,建议用服务名
routes:
- id: msbd-auth
#uri: http://localhost:8803
uri: lb://msbd-auth
predicates:
- Path=/oauth/**
- id: mall-order
#uri: http://localhost:8803
uri: lb://mall-order
predicates:
- Path=/order/**
- id: mall-product
#uri: http://localhost:8804
uri: lb://mall-product
predicates:
- Path=/product/**
- id: mall-websocket
#uri: http://localhost:8804
uri: lb:ws://mall-websocket
predicates:
- Path=/websocket/**
# 配置RSA的公钥访问地址
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: 'http://localhost:8801/rsa/publicKey'
# 自定义白名单
secure:
ignore:
urls: #配置白名单路径
- "/actuator/**"
- "/oauth/token"
3、安全配置,由于Gateway使用的是WebFlux,所以需要使用@EnableWebFluxSecurity注解开启
@Configuration
@EnableWebFluxSecurity
@RequiredArgsConstructor
public class ResourceServerConfig {
private final AuthorizationManager authorizationManager;
private final RestAccessDeniedHandler restAccessDeniedHandler;
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final IgnoreUrlsConfig ignoreUrlsConfig;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
http.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter())
//.jwkSetUri() 远程获取公钥,默认读取的key是spring.security.oauth2.resourceserver.jwt.jwk-set-uri
//.publicKey(rsaPublicKey())
//.jwtDecoder(jwtDecoder())
;
http.authorizeExchange()
//白名单配置
.pathMatchers(ArrayUtil.toArray(ignoreUrlsConfig.getUrls(),String.class)).permitAll()
//鉴权管理器配置
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
//处理未授权
.accessDeniedHandler(restAccessDeniedHandler)
//处理未认证
.authenticationEntryPoint(restAuthenticationEntryPoint)
.and().csrf().disable();
return http.build();
}
/**
* 重新定义权限管理器,默认转换器JwtGrantedAuthoritiesConverter
* @return
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
/**
* 本地获取JWT验签公钥
* @return
*/
@Bean
public RSAPublicKey rsaPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
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;
}
/**
* 解码jwt
* @return
* @throws NoSuchAlgorithmException
* @throws IOException
* @throws InvalidKeySpecException
*/
@Bean
public ReactiveJwtDecoder jwtDecoder() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
return NimbusReactiveJwtDecoder.withPublicKey(rsaPublicKey())
.signatureAlgorithm(SignatureAlgorithm.RS256)
.build();
}
}
4、鉴权管理器配置,在WebFluxSecurity中自定义鉴权操作需要实现ReactiveAuthorizationManager接口
@Component
@RequiredArgsConstructor
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final RedisTemplate<String,Object> redisTemplate;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
//从Redis中获取当前路径可访问角色列表
URI uri = authorizationContext.getExchange().getRequest().getURI();
Object obj = redisTemplate.opsForHash().get(AuthConstant.RESOURCE_ROLES_MAP, uri.getPath());
List<String> authorities = Convert.toList(String.class,obj);
authorities = authorities.stream().map(i -> i = AuthConstant.AUTHORITY_PREFIX + i).collect(Collectors.toList());
//认证通过且角色匹配的用户可访问当前路径
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(authorities::contains)
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
5、全局过滤器AuthGlobalFilter,当鉴权通过后将JWT令牌中的用户信息解析出来,然后存入请求的Header中,这样后续服务就不需要解析JWT令牌了,可以直接从请求的Header中获取到用户信息。
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
try {
//从token中解析用户信息并设置到Header中去
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
log.info("AuthGlobalFilter.filter() user:{}",userStr);
ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
e.printStackTrace();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
六、功能演示
微服务架构中所有请求均通过网关访问,并引入统一认证鉴权功能。
1、启动服务
先启动Nacos服务,如下
访问Nacos服务如下
然后启动gateway网关服务,gateway服务在本地启动,在idea中启动即可。
然后启动auth认证授权服务,auth服务也在本地启动,在idea中启动即可。
最后启动order订单服务,order服务也在本地启动,在idea中启动即可。
最后启动product商品服务,product服务也在本地启动,在idea中启动即可。
在Nacos中观察如下
2、访问/oauth/token获取令牌,此处使用密码模式获取JWT令牌,分别通过直接访问认证授权服务和访问网关服务进行路由到认证授权服务获取JWT令牌,如下
可见通过认证授权服务和网关服务均可以访问获取JWT令牌。
3、访问order订单服务,分别通过直接访问订单服务和访问网关服务进行路由到订单服务获取数据返回,如下
3、访问order订单服务(远程调用了product商品服务),分别通过直接访问订单服务和访问网关服务进行路由到订单服务获取数据返回,如下
还需深入理解其底层原理,加油吧,少年。