之前利用SpringSecurity OAuth2搭建了一个认证服务器,并且结合网关搭建了一个安全认证的架构,而这个架构也是有缺点的,很明显的就是用户的信息是通过加在请求头到微服务去取出来的,这样就会容易泄露用户的信息并且很容易被别人截获请求伪造用户信息,而且网关每一次都要请求认证服务器去验证token是否正确,加重了认证服务器的负担。那么这里我们解决这两个问题用的就是jwt。
关于jwt的组成这里不再述说,大家可自行去看网上的其他博客,这里主要说说jwt的作用。
首先jwt也是利用的token去验证身份,但是这个token里面是有内容能被解析出来的,通常我们都把一些用户信息放在这里面,而jwt是通过base64去编码成的token,不是加密的,所以我们也尽量不要把一些用户重要的信息放在里面。那么既然jwt不是加密的那么我们用它有什么用的,其实它的主要作用并不是加密,而是防止伪造,jwt是通过一个key值去参与生成的,只要我们保证这个key值是不被别人所获取的,那么别人就无法伪造一个token去通过jwt的解析了。
大体的实现架构就是这样,由于SpringSecurity OAuth2已经封装了JWT的具体认证逻辑,所以在网关这里我们就不用再利用过滤器去认证token了,而是把网关和其他的微服务都当作是资源服务器交给SpringSecurity OAuth2去认证token。
之前把token存储在数据库的这种方式换成jwt这种方式的,并且配置tokenKeyAccess("isAuthenticated()")表示从认证服务器中拿key值时需要客户端信息验证。
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailServiceImpl userDetailService;
@Bean
public TokenStore tokenStore(){
//return new JdbcTokenStore(dataSource);
return new JwtTokenStore(jwtTokenEnhancer());
}
@Bean
public JwtAccessTokenConverter jwtTokenEnhancer() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("123456");
return converter;
}
/**
* 配置authenticationManager用于认证的过程
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//设置tokenStore,生成token时会向数据库中保存
.userDetailsService(userDetailService)
.tokenStore(tokenStore())
.tokenEnhancer(jwtTokenEnhancer())
.authenticationManager(authenticationManager);
}
/**
* 重写此方法用于声明认证服务器能认证的客户端信息
* 相当于在认证服务器中注册哪些客户端(包括资源服务器)能访问
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// clients.inMemory()
// .withClient("orderApp") //声明访问认证服务器的客户端
// .secret(passwordEncoder.encode("123456")) //客户端访问认证服务器需要带上的密码
// .scopes("read","write") //获取token包含的哪些权限
// .accessTokenValiditySeconds(3600) //token过期时间
// .resourceIds("order-service") //指明请求的资源服务器
// .authorizedGrantTypes("password") //密码模式
// .and()
// //资源服务器拿到了客户端请求过来的token之后会请求认证服务器去判断此token是否正确或者过期
// //所以此时的资源服务器对于认证服务器来说也充当了客户端的角色
// .withClient("order-service")
// .secret(passwordEncoder.encode("123456"))
// .scopes("read")
// .accessTokenValiditySeconds(3600)
// .resourceIds("order-service")
// .authorizedGrantTypes("password");
//把客户端的信息以及token都存储在数据库中
clients.jdbc(dataSource);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.tokenKeyAccess("isAuthenticated()")
.checkTokenAccess("isAuthenticated()");
}
}
对于网关来说由于我们不用自己去实现验证token的逻辑了,而是完全交给SpringSecurity OAuth2去完成,所以我们把之前实现的filter可以不要了,然后加上SpringSecurity OAuth2的依赖
org.springframework.cloud
spring-cloud-starter-oauth2
在application.yml配置文件中加上:
security:
oauth2:
client:
client-id: gateway
client-secret: 123456
resource:
jwt:
key-uri: http://localhost:9000/oauth/token_key #服务一启动就会请求认证服务器拿到jwt的key值
这个配置就是在服务一启动的时候就会向认证服务器请求拿到key值(一定要认证服务器先启动,否则请求不到的话该服务也会启动失败),并且需要提供客户端(这里指网关)的client-id和client-secret给认证服务器,认证服务器去数据库中判断是否有该客户端的信息,有的话就返回key值给客户端,当有请求过来的时候网关会自动去拿到http basic规范中请求头的token然后通过拿到的key值去判断解析该token是否是正确,正确的话就把请求继续转发给下面的微服务。
这里和网关改造的地方差不多
org.springframework.cloud
spring-cloud-starter-oauth2
security:
oauth2:
client:
client-id: order-service
client-secret: 123456
resource:
jwt:
key-uri: http://localhost:9000/oauth/token_key #服务一启动就会请求认证服务器拿到jwt的key值
当我们想获取用户信息的时候,我们可以在接口上加上@AuthenticationPrincipal注解,用法和之前的说的一样
@PostMapping("/create")
public OrderInfo order(@RequestBody OrderInfo orderInfo,@AuthenticationPrincipal String username){
PriceInfo priceInfo = restTemplate.getForObject("http://localhost:9002/price/get", PriceInfo.class);
orderInfo.setPrice(priceInfo.getPrice());
log.info("order() username=========" + username);
return orderInfo;
}
上面我们只是说了token的认证,这里我们说下关于权限的控制,对于OAuth2来说,权限的控制有两种方式,一种是通过客户端的scope来区分控制权限,这种是只针对客户端权限来说的,并不能针对到某一个用户的权限进行区分,而第二种就是能针对到具体的用户进行区分权限了,我们这里分别来说说两种方式在SpringSecurity中是如何实现的。
我们可以在微服务接口上面加上注解@PreAuthorize(“#oauth2.hasScope(‘具体的scope’)”)
@PostMapping("/create")
@PreAuthorize("#oauth2.hasScope('fly')")
public OrderInfo order(@RequestBody OrderInfo orderInfo,@AuthenticationPrincipal String username){
PriceInfo priceInfo = restTemplate.getForObject("http://localhost:9002/price/get", PriceInfo.class);
orderInfo.setPrice(priceInfo.getPrice());
log.info("order() username=========" + username);
return orderInfo;
}
并且要让该注解生效的话还需要在启动类上加上注解@EnableGlobalMethodSecurity(prePostEnabled=true)
@Configuration
@SpringBootApplication
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true) //让@PreAuthorize注解生效
public class OrderApplication {
// @Autowired
// private RestTemplateBuilder restTemplateBuilder;
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
@Bean
public OAuth2RestTemplate oAuth2RestTemplate(OAuth2ProtectedResourceDetails resource, OAuth2ClientContext context) {
return new OAuth2RestTemplate(resource,context);
}
}
如果没有该权限的话
在微服务接口加上@PreAuthorize(“hasRole(‘具体的角色’)”)
@PostMapping("/create")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public OrderInfo order(@RequestBody OrderInfo orderInfo,@AuthenticationPrincipal String username){
PriceInfo priceInfo = restTemplate.getForObject("http://localhost:9002/price/get", PriceInfo.class);
orderInfo.setPrice(priceInfo.getPrice());
log.info("order() username=========" + username);
return orderInfo;
}
这个role是在认证服务器进行用户验证(UserDetailServiceImpl)的时候查询数据库的得到的,然后存储在jwt的token中。
这种注解的方式虽然说能具体到某个用户的权限,但是每当我们改变权限规则的时候就要重启服务,这样有点麻烦,所以SpringSecurity OAuth2还提供了另外一种比较方便优雅的方式给我们去实现。
@Configuration
@EnableResourceServer //把网关服务配置成一个资源服务器
public class GatewaySecurityConfig extends ResourceServerConfigurerAdapter {
@Autowired
private GatewayWebSecurityExpressionHandler gatewayWebSecurityExpressionHandler;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.expressionHandler(gatewayWebSecurityExpressionHandler);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/auth-center/**").permitAll() //转发向认证服务器的请求不用进行认证
.anyRequest().access("#permissionService.hasPermission(request, authentication)");
}
}
我们在配置中配置#permissionService.hasPermission(request, authentication),表示当请求过来的时候需要经过该方法判断是否有权限通过,但是框架并不知道这个表达式的意思,所以需要我们把这个表达式注册进框架使框架认识这个表达式,所以我们就需要一个适配器去配置了
@Component
public class GatewayWebSecurityExpressionHandler extends OAuth2WebSecurityExpressionHandler {
@Autowired
private PermissionService permissionService;
@Override
protected StandardEvaluationContext createEvaluationContextInternal(Authentication authentication, FilterInvocation invocation) {
StandardEvaluationContext standardEvaluationContext = super.createEvaluationContextInternal(authentication, invocation);
standardEvaluationContext.setVariable("permissionService",permissionService);
return standardEvaluationContext;
}
}
而与权限相关的逻辑我们就需要自己去实现一个类了,在这个类中主要进行权限的判断,可以调用redis或者数据库里面的权限数据去判断当前用户是否有权限请求当前资源,返回true表示允许请求,false表示拒绝请求。
@Slf4j
@Service
public class PermissionServiceImpl implements PermissionService {
@Override
public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
log.info("request url : " + request.getRequestURI());
log.info("authentication : " + authentication.toString());
return RandomUtils.nextInt(0,20) % 2 == 0;
}
}
之前说过我们完整的一套认证流程是需要经过限流,认证,记录日志,授权这4个步骤的,而SpringSecurity OAuth2已经帮我们实现了认证和授权,如果我们需要加入记录日志这个步骤的话就需要自定义过滤器加入到SpringSecurity OAuth2的过滤器链中了。
加入一个过滤器很简单,实现一个web过滤器就可以了
@Slf4j
public class GatewayAuditLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
log.info("add log for user " + username);
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
在这个日志过滤器中我们从SpringSecurity的上下文中取出当前请求认证通过的用户名并简单打印出来(如果客户端传过来的token时候错误的话,SpringSecurity OAuth2的认证过滤器会直接拒绝掉该请求并返回错误信息,此时就不会经过后面的日志过滤器了,而如果没有传token过来的话认证是可以通过的但是在日志过滤器这里拿到的用户名是一个匿名用户)
继承OAuth2AuthenticationEntryPoint这个类,这个类是专门处理401异常的,如果传了错误的token或者没有传token并且没有配置该请求是不需要权限的,那么就会返回给客户端错误提示的json,这个json就是这个类生成的,所以我们如果需要记录401的日志的话,就需要继承这个类在里面去处理了
/**
* OAuth2AuthenticationEntryPoint该类是专门处理401状态异常的
*/
@Component
@Slf4j
public class GatewayAuthenticationEntryPoint extends OAuth2AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
//日志记录401
log.info("add log 401");
super.commence(request, response, authException);
}
}
继承OAuth2AccessDeniedHandler这个类,该类和上面一样,只不过是专门处理403状态异常的
/**
* OAuth2AccessDeniedHandler该类是专门处理403状态异常的
*/
@Component
@Slf4j
public class GatewayAccessDeniedHandler extends OAuth2AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException {
//记录日志
log.info("add log 403");
super.handle(request, response, authException);
}
}
最后这两个状态异常都需要配置才能生效
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
//.expressionHandler(gatewayWebSecurityExpressionHandler);
.accessDeniedHandler(gatewayAccessDeniedHandler)
.authenticationEntryPoint(gatewayAuthenticationEntryPoint);
}
最后,总的架构应该是这样的一个流程,前端通过登录经过网关转发从认证服务器那边拿到jwt token,之后每次请求都带上这个jwt token经过网关,在网关这边去进行认证鉴权,而网关鉴权需要的角色权限数据就是从数据库或者redis中拿到的,这些数据由权限服务(专门负责角色权限数据的服务)进行增删改到数据库或者redis中,网关认证鉴权通过了之后就能转发到后面相应的微服务了,而后面的微服务在拿到网关转发过来的jwt token之后又再进行认证,认证成功之后能顺利访问到了。