畅购商城(十):购物车

好好学习,天天向上

本文已收录至我的Github仓库DayDayUP:github.com/RobodLee/DayDayUP,欢迎Star,更多文章请前往:目录导航

OAuth2.0对接用户微服务

上一篇文章中提到过,访问资源服务的时候,需要携带令牌去进行权限校验。那么用户微服务也是资源服务,所以需要对其进行配置,首先添加OAuth2.0的依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

然后把之前导出的public.key放到用户微服务的resources目录下。最后添加一个配置类即可,配置类上需要添加@EnableResourceServer注解,然后继承自ResourceServerConfigurerAdapter

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    private static final String PUBLIC_KEY = "public.key";//公钥

    /***
     * 定义JwtTokenStore
     * @param jwtAccessTokenConverter
     * @return
     */
    @Bean
    public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
        return new JwtTokenStore(jwtAccessTokenConverter);
    }

    /***
     * 定义JJwtAccessTokenConverter
     * @return
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setVerifierKey(getPublicKey());
        return converter;
    }
    /**
     * 获取非对称加密公钥 Key
     * @return 公钥 Key
     */
    private String getPublicKey() {
        Resource resource = new ClassPathResource(PUBLIC_KEY);
        try {
            InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
            BufferedReader br = new BufferedReader(inputStreamReader);
            return br.lines().collect(Collectors.joining("\n"));
        } catch (IOException ioe) {
            return null;
        }
    }

    /***
     * Http安全配置,对每个到达系统的http请求链接进行校验
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        //所有请求必须认证通过
        http.authorizeRequests()
                //下边的路径放行
                .antMatchers("/user/add,user/load/*").permitAll() //配置 /user/add /user/load/*不需要权限
                .anyRequest().authenticated();    //其他地址需要认证授权
    }

}

这样配置类就配置好了,在添加了@EnableGlobalMethodSecurity注解后,就可以在指定的方法上面添加注解来进行权限控制。

比如:

@PreAuthorize("hasAnyAuthority('admin')")   //表示该方法只有admin才能访问
@PutMapping(value = "/{id}")
public Result update(@RequestBody User user, @PathVariable String id) {
………………

这样用户微服务就配置好了。

网关配置

访问微服务的JWT令牌是放在请求头中一个叫“Authorization”的参数中。以“bearer”开头。因为请求是通过网关转发给相应的微服务,所以可以对网关进行配置。之前在网关微服务中写了一个过滤器叫AuthorizeFilter,里面的filter()方法写的是分别从请求头、参数、Cookie中获取token信息,然后进行校验。现在稍微修改一下,现在不校验了,只判断有无token信息并进行简单处理。

还有一点就是有些请求比如登录注册等不需要token的就直接放行。

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    …………
    if (needlessToken(request.getURI().toString())) {
        return chain.filter(exchange);  //如果是不需要token的请求就直接放行
    }
    //还是没有Token就拦截
    if (StringUtils.isEmpty(token)){
        response.setStatusCode(HttpStatus.UNAUTHORIZED);
        return response.setComplete();
    } else {
        if (!hasTokenInHeader) {
            if (!(token.startsWith("brarer ") || token.startsWith("Bearer "))) {
                token = "Bearer " + token;
            }
            request.mutate().header("Authorization",token);
        }
    }
    //Token不为空就校验Token
    // try {
    //     JwtUtil.parseJWT(token);
    // } catch (Exception e) {
    //     //报异常说明Token是错误的,拦截
    //     response.setStatusCode(HttpStatus.UNAUTHORIZED);
    //     return response.setComplete();
    // }
    return chain.filter(exchange);
}

//判断指定的uri是否不需要token就可以访问,true表示不需要
public boolean needlessToken(String uri) {
    String[] uris = new String[]{
            "/api/user/add",
            "/api/user/login"
    };
    for (String s : uris) {
        if (s.equals(uri)) {
            return true;
        }
    }
    return false;
}

代码的意思就是如果请求头中有token的信息就不去管它,如果token在参数或者Cookie中,就看是不是以“**Bearer **”开头,不是的话就添加“Bearer ”,然后存入请求头中。如果是指定的不需要token的请求就直接放行。为什么现在不校验呢?因为校验的工作交给对应的微服务去处理了,网关不负责。

OAuth2.0从数据库加载数据

在之前的配置中,客户端的信息是配置在内存中的。用户也是在内存中指定的。现在来改造一下,从数据库中加载数据。首先是客户端信息,修改AuthorizationServerConfig中的configure()方法。

// 客户端信息配置
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.jdbc(dataSource).clients(clientDetails());
}
//客户端配置
@Bean
public ClientDetailsService clientDetails() {
    return new JdbcClientDetailsService(dataSource);
}

我们给客户端配置了一个JdbcClientDetailsService

然后就可以从oauth_client_details中加载数据了。

image

咦?好像没有在任何地方指定这张表呀,怎么就能从这张表里面加载数据???前面不是配了个JdbcClientDetailsService么。点进去看看

image

哦~ 原来是在JdbcClientDetailsService的内部指定好了呀。


现在就是从数据库中加载了客户端的信息,那怎么加载用户的信息呢?这里使用Feign接口去调用用户微服务查询出用户的信息。

//用户微服务中的UserController
@GetMapping({"/{id}","/load/{id}"})
public Result<User> findById(@PathVariable String id) {
    //调用UserService实现根据主键查询User
    User user = userService.findById(id);
    return new Result<User>(true, StatusCode.OK, "查询成功", user);
}

我们给这个方法配置一个“load/{id}”的路径。之前已经配置过这个路径放行了。

然后在用户的api微服务中添加一个Feign接口。

@FeignClient("user")
@RequestMapping("/user")
public interface UserFeign {

    //根据ID查询User数据
    @GetMapping({"/load/{id}"})
    Result<User> findById(@PathVariable String id);

}

这样在认证微服务的UserDetailsServiceImplloadUserByUsername()方法中就可以调用Feign去数据库中查询用户信息了。

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    /*客户端信息认证*/
    //取出身份,如果身份为空说明没有认证
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    //没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret,开始认证client_id和client_secret
    if(authentication==null){
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
        if(clientDetails!=null){
            //秘钥
            String clientSecret = clientDetails.getClientSecret();
            //数据库查找方式
            return new User(username,clientSecret, AuthorityUtils.commaSeparatedStringToAuthorityList(""));
        }
    }

    /*用户信息认证*/
    if (StringUtils.isEmpty(username)) {
        return null;
    }
    com.robod.user.pojo.User user = userFeign.findById(username).getData(); //查询用户
    if (user == null ) {
        return null;
    }
    //根据用户名查询用户信息
    String pwd = user.getPassword();
    //创建User对象
    String permissions = "goods_list,seckill_list";
    UserJwt userDetails = new UserJwt(username,pwd,
                                       AuthorityUtils.commaSeparatedStringToAuthorityList(permissions));
    return userDetails;
}

这样就OK了,来测试一下。

image

这个robod用户是保存在数据库中的,可以正常登录,说明我们的配置是没有问题的。

微服务之间的令牌传递

在实现购物车功能之前,要先明确一个问题,只有登录过的用户才可以访问自己的购物车。所以我们在访问微服务的时候必须要携带令牌,但是还涉及到微服务之间的Feign接口调用,令牌该怎么传递过去呢?令牌不是放在名为“Authorization”的请求头中吗,所以加一个过滤器在Feign调用前调用,将当前请求的请求头信息封装到Feign请求的请求头中。但是呢,这个过滤器是很多微服务共用的,所以可以将这个过滤器放在common工程中,哪个微服务需要就直接注入到Spring容器中。

public class FeignHeaderInterceptor implements RequestInterceptor {

    //Feign调用前调用
    @Override
    public void apply(RequestTemplate template) {
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (requestAttributes != null) {
            HttpServletRequest request = requestAttributes.getRequest();
            Enumeration<String> headerNames = request.getHeaderNames();//所有请求头的名字集合
            if (headerNames != null) {
                while (headerNames.hasMoreElements()) {
                    String headerName = headerNames.nextElement();
                    String headerValue = request.getHeader(headerName);
                    template.header(headerName,headerValue);
                }
            }
        }
    }
}

如果哪个微服务想要调用的话,就直接在启动类中注入即可。

@Bean
public FeignHeaderInterceptor feignHeaderInterceptor() {
    return new FeignHeaderInterceptor();
}

这样就可以实现令牌在不同微服务之间的传递。

购物车

现在就可以来实现购物车的功能了,因为购物车毕竟是用来下单的,所以就将购物车功能写在订单微服务中。创建一个订单微服务changgou-service-order以及一个订单的api工程changgou-service-order-api。

从令牌中获取用户名

添加到购物车的流程就是用户从前端将商品的id和数量以及令牌传过来。然后解析令牌,拿到用户名。然后拿着商品的id用Feign调用Goods微服务将Sku和Spu查询出来,然后封装成一个OrderItem对象,存入Reids中。这个时候Feign接口的调用就涉及到微服务之间的令牌传递问题了,把FeignHeaderInterceptor注入即可。

这里还有一个问题,就是令牌解析,很简单,使用公钥解析即可,封装一个工具类com.robod.order.utils.TokenDecodeUtil

@Component
public class TokenDecodeUtil {

    //公钥路径
    private static final String PUBLIC_KEY_PATH = "public.key";

    //公钥的内容
    private static String publicKey="";

    /***
     * 获取用户信息
     * @return
     */
    public Map<String,String> getUserInfo() {
        //获取授权信息
        OAuth2AuthenticationDetails authentication = 
            (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
        //令牌解码
        return decodeToken(authentication.getTokenValue());
    }

    /***
     * 读取令牌数据
     */
    public Map<String,String> decodeToken(String token){
        //校验Jwt
        Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(getPubKey()));

        //获取Jwt原始内容
        String claims = jwt.getClaims();
        return JSON.parseObject(claims,Map.class);
    }

    /**
     * 获取非对称加密公钥 Key
     * @return 公钥 Key
     */
    public String getPubKey() {
        if(!StringUtils.isEmpty(publicKey)){
            return publicKey;
        }
        Resource resource = new ClassPathResource(PUBLIC_KEY_PATH);
        try {
            InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
            BufferedReader br = new BufferedReader(inputStreamReader);
            publicKey = br.lines().collect(Collectors.joining("\n"));
            return publicKey;
        } catch (IOException ioe) {
            return null;
        }

    }

}

准备工作做好以后就可以编写相应的逻辑了。

添加到购物车

//     CartController
@GetMapping("/add")
public Result add(long id,int num) {
    String username = tokenDecodeUtil.getUserInfo().get("username");
    cartService.add(id,num,username);
    return new Result(true, StatusCode.OK,"成功添加到购物车");
}
//-----------------------------------------------------------------------
//     CartServiceImpl
@Override
public void add(long id, int num, String username) {
    BoundHashOperations boundHashOperations = redisTemplate.boundHashOps("Cart_" + username);
    if (num <= 0){
        boundHashOperations.delete(id);
        Long size = boundHashOperations.size();
        if (size == null || size<=0) {
            redisTemplate.delete("Cart_" + username);
        }
        return;
    }
    Sku sku = skuFeign.findById(id).getData();
    if (sku == null) {
        throw new RuntimeException("未查询到商品信息");
    }
    Spu spu = spuFeign.findById(sku.getSpuId()).getData();
    if (spu == null) {
        throw new RuntimeException("数据库中数据异常");
    }
    OrderItem orderItem = createOrderItem(spu,sku,num);
    boundHashOperations.put(id,orderItem);
}

private OrderItem createOrderItem(Spu spu,Sku sku,int num) {
    OrderItem orderItem = new OrderItem();
    orderItem.setCategoryId1(spu.getCategory1Id());
    orderItem.setCategoryId2(spu.getCategory2Id());
    orderItem.setCategoryId3(spu.getCategory3Id());
    orderItem.setSpuId(spu.getId());
    orderItem.setSkuId(sku.getId());
    orderItem.setName(sku.getName());
    orderItem.setNum(num);
    orderItem.setPrice(sku.getPrice());
    orderItem.setMoney(num * sku.getPrice());
    orderItem.setImage(spu.getImage());
    return orderItem;
}

但是现在访问的话还存在问题,FeignHeaderInterceptor里面ServletRequestAttributes的数据

image

这是因为现在的feign的隔离策略是THREAD(线程池隔离),这时候使用Feign的时候是单独开启一个线程的,不是之前的线程,所以获取不到数据。要想使用同一个线程去使用feign,可以把隔离策略设置成SEMAPHORE(信号量隔离)。在order微服务的配置文件中添加一段配置

#hystrix 配置
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 10000
          strategy: SEMAPHORE

这两种隔离策略的区别是

线程池隔离 信号量隔离
线程 与调用线程非相同线程 与调用线程相同(jetty线程)
开销 排队、调度、上下文开销等 无线程切换,开销低
异步 支持 不支持
并发支持 支持(最大线程池大小) 支持(最大信号量上限)

这样就可以正常地添加数据到购物车了。

image

查询购物车

查询购物车的功能很简单,前端携带着令牌向服务器发送请求,Controller调用相应的方法解析令牌拿到用户名,然后调用Service层从Redis中获取到对应的数据。

//  CartController
@GetMapping("/list")
public Result<List<OrderItem>> list() {
    String username = tokenDecodeUtil.getUserInfo().get("username");
    List<OrderItem> orderItems = cartService.list(username);
    return new Result<>(true,StatusCode.OK,"查询购物车成功",orderItems);
}
//--------------------------------------------------------------------------
//     CartServiceImpl
@Override
public List<OrderItem> list(String username) {
    return (List<OrderItem>) redisTemplate.boundHashOps("Cart_" + username).values();
}
image

权限控制

虽然购物车的功能已经实现了,但是还存在一个问题,就是没有对用户进行权限校验,我们可以去限制某个方法只能由拥有特定权限的用户去访问,要是不进行权限控制的话,任何人都可以访问就会很不安全。这里我准备对购物车用到的四个方法添加“USER”权限的控制。

首先为订单微服务和商品微服务添加OAuth2.0的依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

然后将之前提取出来的public.key文件添加到这两个微服务的resources目录下。最后添加资源服务的配置类

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    private static final String PUBLIC_KEY = "public.key";//公钥

    /***
     * 定义JwtTokenStore
     * @param jwtAccessTokenConverter
     * @return
     */
    @Bean
    public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
        return new JwtTokenStore(jwtAccessTokenConverter);
    }

    /***
     * 定义JJwtAccessTokenConverter
     * @return
     */
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setVerifierKey(getPublicKey());
        return converter;
    }

    /**
     * 获取非对称加密公钥 Key
     * @return 公钥 Key
     */
    private String getPublicKey() {
        Resource resource = new ClassPathResource(PUBLIC_KEY);
        try {
            InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
            BufferedReader br = new BufferedReader(inputStreamReader);
            return br.lines().collect(Collectors.joining("\n"));
        } catch (IOException ioe) {
            return null;
        }
    }

    /***
     * Http安全配置,对每个到达系统的http请求链接进行校验
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        //所有请求必须认证通过
        http.authorizeRequests()
                .anyRequest().permitAll();//.authenticated();    //其他地址需要认证授权
    }

}

这里限制所有的请求都必须通过验证。然后我们在相应的方法上添加注解即可。

//  SkuController
@GetMapping("/{id}")
@PreAuthorize("hasAnyAuthority('USER')")
public Result<Sku> findById(@PathVariable Long id){

//  SpuController
@GetMapping("/{id}")
@PreAuthorize("hasAnyAuthority('USER')")
public Result<Spu> findById(@PathVariable Long id){
    
//  CartController
@GetMapping("/add")
@PreAuthorize("hasAnyAuthority('USER')")
public Result add(long id,int num) {

@GetMapping("/list")
@PreAuthorize("hasAnyAuthority('USER')")
public Result<List<OrderItem>> list() {

这四个方法限制了只有拥有USER权限才可以访问,如果没有相应的权限请求就会被拒绝。

image

订单微服务对接网关

现在就差最后一步了,就是将订单微服务对接到网关,然后就可以通过网关将请求转发到订单微服务了。

在网关微服务的配置文件中添加订单微服务的路由配置

spring:
  cloud:
      routes:
        - id: changgou_order_route
          uri: http://localhost:18089
          predicates:
            -Path=/api/cart/**,/api/categoryReport/**,/api/orderConfig/**,
            /api/order/**,/api/orderItem/**,/api/orderLog/**,/api/preferential/**,
            /api/returnCause/**,/api/returnOrder/**,/api/returnOrderItem/**
          filters:
            - StripPrefix=1

这样就OK了。

image

总结

原本的代码中,OAuth2.0是从内存中获取数据,文章的开头先是改了代码从数据库中获取数据。然后实现了购物车的功能并实现了权限控制。最后将订单微服务对接到了网关中,实现了通过网关去访问相应的微服务。

如果我的文章对你有些帮助,不要忘了点赞收藏转发关注。要是有什么好的意见欢迎在下方留言。让我们下期再见!

image