多系统-单点登录
一处登录,处处登录。比如登录微博,则新浪旗下的其他产品也处于登录状态了
单点登录 (SSO) 是一种身份验证功能,允许用户使用一组登录凭据访问多个应用程序。
企业通常利用 SSO 来更加简单便捷地访问各种网络、内部和云应用,从而获得更好的用户体验。
1、Cookie作用域
domain:作用域名
domain参数 | atguigu.com | sso.atguigu.com | order.atguigu.com |
---|---|---|---|
atguigu.com | √ | √ | √ |
sso.atguigu.com | × | √ | × |
order.atguigu.com | × | × | √ |
domain有两点要注意:
1. domain参数可以设置父域名以及自身,但不能设置其它域名,包括子域名,否则cookie不起作用。
2. cookie的作用域是domain本身以及domain下的所有子域名。
cookie的路径(Path):
默认设置/标识项目根路径,访问项目任何位置都会携带
cookie.setDomain("atguigu.com");//设置cookie的作用域名 省略默认当前域名
cookie.setPath("/hello"); //设置cookie作用的路径 省略 默认/
2、有状态登录
用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
-
服务端保存大量数据,增加服务端压力
-
服务端保存用户状态,无法进行水平扩展
-
客户端请求依赖服务端,多次请求必须访问同一台服务器
即使使用redis保存用户的信息,也会损耗服务器资源。
3、无状态登录(推荐)
服务端不保存任何客户端请求者信息,客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
-
客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
-
服务端的集群和状态对客户端透明
-
服务端可以任意的迁移和伸缩
-
减小服务端存储压力
无状态登录的流程:
-
当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
-
认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
-
以后每次请求,客户端都携带认证的token
-
服务的对token进行解密,判断是否有效。
整个登录过程中,最关键的点是什么?
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。
采用何种方式加密才是安全可靠的呢?
我们将采用
JWT + RSA非对称加密
4、单点登录(生成token)
JWT签发的token中包含了用户的身份信息,并且客户端每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。
代码实现如下:
//远程查询用户数据 (包含验证密码) ResponseVo responseVo = umsClient.query(loginName, password); if(responseVo.getCode()!=0){ return null; } //构建jwt token Object obj = responseVo.getData(); ObjectMapper mapper = new ObjectMapper(); UserEntity userEntity = mapper.convertValue(obj, UserEntity.class); Map<String, Object> map = new HashMap<>(); map.put("userId" , userEntity.getId()); map.put("username" , userEntity.getUsername()); map.put("ip" , IpUtils.getIpAddressAtService(request)); //使用秘钥签名:防止数据被篡改 //map中可以设置本次登录的客户端ip地址: try { //使用私钥生成token String token = JwtUtils.generateToken(map, jwtProperties.getPrivateKey(), jwtProperties.getExpire() * 24 * 7); //将token设置到cookie中交给客户端: response //把token设置到cookie中 CookieUtils.setCookie(request,response,"GMALL-TOKEN",token , jwtProperties.getExpire() * 24 * 7); //交给前端回显登录信息的cookie CookieUtils.setCookie(request,response,"unick",userEntity.getNickname() , jwtProperties.getExpire() * 24 * 7); return token; return "1"; } catch (Exception e) { e.printStackTrace(); } return "0";
GateWay自定义局部过滤器(登录验证)
网关过滤器分为 全局过滤器和局部过滤器,本次使用的是自定义局部过滤器,验证登录状态。
理由如下:很多接口都需要用户登录以后才能访问,比如“加入购物车”,所以选择在网关服务做登录校验
1、自定义局部过滤器工厂
如下,只是雏形。。在apply方法中还有一大堆业务代码没写呢。也可以看成是一个模板,没啥可变性。。。
@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.PathConfig> {
/**
* 一定要重写构造方法
* 告诉父类,这里使用PathConfig对象接收配置内容
*/
public AuthGatewayFilterFactory() {
super(PathConfig.class);
}
@Override
public GatewayFilter apply(PathConfig config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
//获取请求路径
System.out.println(request.getURI());
System.out.println(request.getURI().getPath());
System.out.println("我是局部过滤器!!!" + config);
return chain.filter(exchange);
};
}
@Override
public String name() {
return "auth";
}
@Override
public List<String> shortcutFieldOrder() {
//通过一个集合字段读取所有的路径
return Arrays.asList("authPaths");
}
@Override
public ShortcutType shortcutType() {
//纯字符串列表,多个字符串使用逗号分割
return ShortcutType.GATHER_LIST;
}
/**
* 自定义静态内部类PathConfig,接收路由配置的参数列表
*/
@Data
public static class PathConfig{
//authPaths代表需要登录验证的路径列表
private List<String> authPaths;
}
}
2、配置文件的filters配置参数
网关怎么判断请求是否需要过滤
方式1:在需要验证的路径中添加一层特殊的路径( /cart/auth/xxx )
方式2:给路由配置的过滤器设置参数列表:告诉过滤器哪些路径需要过滤请求
像下面一样指定`拦截路径`,并在过滤器中获取`拦截路径`,再去判断当前路径是否需要拦截【不要觉得我用词用错了,就是拦截而不是过滤,拦截的话更好理解,过滤的话有歧义】
测试一下:http://sso.gmall.com/login
那如果测试地址换成这样呢 :http://sso.gmall.com/abc
还是会走我们的过滤器,这并没有毛病,并不是说配置文件中写了 - auth=/login,/Login.html,就只能是这俩个请求才能走我们的过滤器,
它俩写在那的作用就是为了让过滤器知道什么样的请求需要在过滤器中做登录验证,参数什么作用完全由我们代码说了算
3、单点登录第二块(统一校验)
1.先判断该请求是否需要做登录验证(有些接口需要登录后才能访问),不需要的话直接放行。
2.需要做登录验证的时候,取出名为"GMALL-TOKEN"的cookie,没有的话则重定向让其去登录。有则是代表当前用户处于登录状态,继续校验token有效期、ip等,然后放行。
3.为了方便起见,顺便把token中存储的userId取出来放到了请求头中,方便以后拿。
@Override
public GatewayFilter apply(PathConfig config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//获取请求路径
System.out.println(request.getURI());
System.out.println(request.getURI().getPath());
String requestPath = request.getURI().getPath();
System.out.println("config.authPaths:"+config.authPaths);
boolean allMatch = config.authPaths.stream().allMatch(path -> path.indexOf(requestPath) == -1);
if (allMatch) {
//无需验证:直接放行
return chain.filter(exchange);
}
//需要解析的token字符串
String token;
//2.存在GMALL-TOKEN的coookie
if (!CollectionUtils.isEmpty(request.getCookies()) && request.getCookies().containsKey("GMALL-TOKEN")) {
MultiValueMap<String, HttpCookie> cookies = request.getCookies();
// token = cookies.get("GMALL-TOKEN").toString();
token = cookies.getFirst("GMALL-TOKEN").getValue();
} else {
//获取名为“GMALL-TOKEN”的cookie 失败,跳转到登录页面
//响应一个重定向的报文 让浏览器访问登录页面
response.setStatusCode(HttpStatus.SEE_OTHER);//设置重定向状态码
response.getHeaders().set(HttpHeaders.LOCATION, "http://sso.gmall.com/toLogin.html?returnUrl=" + request.getURI());
return response.setComplete();//完成响应报文的封装 直接响应
}
//3.有的话解析token中的userId设置到请求头中
PublicKey publicKey = null;
try {
publicKey = RsaUtils.getPublicKey("E:\\rsa.pub");
Map<String, Object> map = JwtUtils.getInfoFromToken(token, publicKey);
//验证ip
String ip =(String) map.get("ip");
if(StringUtils.isEmpty(ip)){
if (!IpUtils.getIpAddressAtGateway(request).equals(ip)) {
//ip不一致
throw new RuntimeException("ip不一致-存在token伪造问题");
}
}
//把userId放到请求头中,就不用以后再解析一遍了
Integer userId =(Integer) map.get("userId");
request.mutate().header("userId",userId+"").build();//重新构建请求报文对象
exchange.mutate().request(request).build();//重新构建交换机对象
//登录验证完成,放行
return chain.filter(exchange);
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("登录校验失败");
}
};
}
4、token解决CSRF攻击
CSRF (Cross Site Request Forgery)一般被翻译为跨站请求伪造。那么什么是跨站请求伪造呢?
说简单一点用”你的身份"去发送一些恶意请求。
对服务器来说这个请求是完全合法的,但是却完成了攻击者所期望的一个操作,比如以你的名义发送邮件、发消息,盗取你的账号,添加系统管理员,甚至于购买商品、虚拟货币转账等
CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的 cookie 来通过安全验证
在我们登录成功获得token之后,一般会选择存放在cookie中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个token,这样就不会出现CSRF漏洞的问题。
因为,即便有个你点击了非法链接发送了请求到服务端,这个非法请求是不会携带token的,所以这个请求将是非法的。
购物车
主要就是下面4处功能
- 校验用户登录状态
- 添加商品到购物车
- 登录状态下查询购物车时合并购物车
- 同步更新购物车价格
1、购物车技术选型
由于购物车是一个读多写多的场景,为了应对高并发场景,所有购物车采用的存储方案也和其他功能,有所差别。
1. redis(登录/未登录):性能高,代价高,不利于数据分析
2. mysql(登录/未登录):性能低,成本低,利于数据分析
随着数据价值的提升,企业越来越重视用户数据的收集;我们采用组合方案:redis + mysql
不管是否登录都把数据保存到mysql,mysql负责存储用户所有的数据 (异步存储) 起到一个数据采集的功能;并引入redis,redis负责存储用户实时添加的购物车数据
- 查询时,从redis查询提高查询速度
- 写入时,采用双写模式
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
-
第一层Map,Key是用户id
-
第二层Map,Key是购物车中商品id,值是购物车数据
redis 采用的hash数据类型来存储,格式:Map(userid,map(skuId,cartItemJson))
购物车,使用redis中的hash结构存储 rediskey field value cart:userId/userKey 代表一个用户的购物车 skuId: 代表唯一的一个商品的购物项 value: 购物项的json
==========
设计购物车表结构
数据库设计的三大范式
- 第一范式: 字段原子性 : 字段不可分割 一个字段只能表示一个含义
- 第二范式: 主键唯一性 : 保证该条记录唯一性
- 第三范式: 字段之间不允许出现信息的传递性 关联关系
根据原型图,设计表
userID,skuID ,images,titile,count,price,checked,store,createtime,updatetime,extra
2、拦截器-校验用户状态
登录校验:购物车的处理方式与用户的登录状态有关,因此需要对用户状态进行校验;而登录状态的校验如果在每个方法中进行校验,会造成代码的冗余,不利于维护。 故项目中我们使用拦截器统一处理
@Component
public class UserInfoInterCeptor implements HandlerInterceptor {
private static ThreadLocal<UserInfo> LOCAL = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("MyInterceptor拦截器的preHandle方法执行....");
UserInfo userInfo = new UserInfo();
//不管是未登录还是已登录,都会创建一个userKey,设置给cookie
String userKey = CookieUtils.getCookieValue(request, "userKey");
if (StringUtils.isEmpty(userKey)) {
// 模拟一个userKey,在未登录时也可以加入商品到购物车 如果userKey是本次创建的 设置为cookie
userKey = UUID.randomUUID().toString().replace("-", "");
CookieUtils.setCookie(request, response, "userKey", userKey, 60 * 60 * 24 * 30);
}
userInfo.setUserKey(userKey);
//拿到userId,如果未登录拿不到
String userId = request.getHeader("userId");
if (!StringUtils.isEmpty(userId)) {
//在网关的登录校验中,就已经把userId放入到请求头中了
userInfo.setUserId(Long.parseLong(userId));
} else {
//针对那些不做登录校验的接口的请求,他们只能自己解析拿到userId
String token = CookieUtils.getCookieValue(request, "GMALL-TOKEN");
if(!StringUtils.isEmpty(token)){
PublicKey publicKey = RsaUtils.getPublicKey("E:\\rsa.pub");
Map<String, Object> map = JwtUtils.getInfoFromToken(token, publicKey);
//验证ip
userId =map.get("userId").toString();
userInfo.setUserId(Long.parseLong(userId));
}
//但是还是有可能是拿不到,未登录的情况下userId是没有值的
}
LOCAL.set(userInfo);
return true;//返回是否放行
}
//在视图渲染完成之后执行,经常在完成方法中释放资源
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LOCAL.remove();
}
//获取userId的值
public static String getUserId() {
return LOCAL.get().getUserId() != null ? LOCAL.get().getUserId() + "" : LOCAL.get().getUserKey();
}
public static UserInfo getUserInfo() {
UserInfo userInfo = LOCAL.get();
return userInfo;
}
}
注册拦截器
@Configuration
public class MvcInterceptorConfig implements WebMvcConfigurer {
@Autowired
UserInfoInterceptor userInfoInterceptor;
@Autowired
AuthJwtProperties jwtProperties;
//注册自定义拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userInfoInterceptor)
.addPathPatterns("/**");
}
}
3、添加购物车
我们的购物车结构是一个双层Map:Map<String,Map<String,String>> 第一层Map,Key是用户id 第二层Map,Key是购物车中商品id,值是购物车数据
/**
* 添加商品到购物车
*
* @param skuId
* @param count
*/
@Override
public void add2Cart(String skuId, Integer count) {
//获取到UserId 有userId使用userId,没有使用userKey
String userId = UserInfoInterCeptor.getUserId();//从拦截器中获取可能是UserId也可能是Userkey
//去redis中查询是否是第一次添加该商品
String cartKey = "cart:" + userId;
BoundHashOperations boundHashOperations = redisTemplate.boundHashOps(cartKey);
if (boundHashOperations.hasKey(skuId)) {
//有代表该购物项中已经存在,是做数量的修改操作
Cart cart = (Cart) boundHashOperations.get(skuId);
cart.setCount(cart.getCount() + count);
boundHashOperations.put(skuId, cart);
//mysql数据库异步更新
cartAsyncService.updateCartInfo(userId,skuId,cart);
} else {
//无代表购物项不存在,做购物项的添加
Cart cart = new Cart();
cart.setSkuId(Long.parseLong(skuId));
cart.setCount(count);
cart.setUserId(userId);
cart.setCheck(true);//默认给true
//远程服务调用
ResponseVo<SkuEntity> skuEntityResponseVo = pmsClient.querySkuById(Long.parseLong(skuId));
ResponseVo<List<SkuAttrValueEntity>> listResponseVo2 = pmsClient.querySearchAttrValueBySkuId(Long.parseLong(skuId));
ResponseVo<List<WareSkuEntity>> listResponseVo = wmsClient.queryWareSkuBySkuId(Long.parseLong(skuId));
ResponseVo<List<ItemSaleVo>> listResponseVo1 = smsClient.querySalesBySkuId(skuId);
SkuEntity skuEntity = skuEntityResponseVo.getData();
List<SkuAttrValueEntity> attrValueEntityList = listResponseVo2.getData();
List<WareSkuEntity> wareSkuEntities = listResponseVo.getData();
List<ItemSaleVo> itemSaleVoList = listResponseVo1.getData();
if (skuEntity != null) {
cart.setDefaultImage(skuEntity.getDefaultImage());
cart.setTitle(skuEntity.getTitle());
cart.setPrice(skuEntity.getPrice());
cart.setCurrentPrice(skuEntity.getPrice());
//设置实时价格
String priceKey="cart:price:"+skuId;
redisTemplate.opsForValue().set(priceKey,skuEntity.getPrice().toString());
}
if (!CollectionUtils.isEmpty(wareSkuEntities)) {
boolean b = wareSkuEntities.stream().anyMatch(wareSkuEntity -> {
//任意有一个仓库能满足货物数量判定为有货
return wareSkuEntity.getStock() - wareSkuEntity.getStockLocked() - count >= 0;
});
cart.setStore(b);
}
cart.setSaleAttrs(JSON.toJSONString(attrValueEntityList));
cart.setSales(JSON.toJSONString(itemSaleVoList));
boundHashOperations.put(skuId, cart);
//mysql数据库异步添加
cartAsyncService.saveCartInfo(userId,cart);
}
}
4、查询(合并)购物车
查询购物车
-
先根据userKey查询未购物车中记录(redis)
-
判断是否登录,未登录直接返回
-
已登录,合并购物车中的记录并删除未登录状态的购物车(redis + mysql)
-
查询购物车记录(redis)
@Override
public List<Cart> queryCarts() {
//1.未登录情况下,直接返回未登录的购物车信息
UserInfo userInfo = UserInfoInterCeptor.getUserInfo();
if (userInfo.getUserId() == null) {
//未登录状态
BoundHashOperations ops = redisTemplate.boundHashOps("cart:" + userInfo.getUserKey());
List<Cart> values = ops.values();
//设置所有未登录购物项的实时价格
values.stream().forEach(cart -> {
if (redisTemplate.hasKey("cart:price:"+cart.getSkuId())) {
//有的话,则更新价格
String newPrice = (String) redisTemplate.opsForValue().get("cart:price:" + cart.getSkuId());
cart.setCurrentPrice(new BigDecimal(newPrice));
ops.put(cart.getSkuId()+"",cart);
}
});
return ops.values();
}
//2.登录情况下,需要合并购物车后返回
//1.未登录时 添加购物车 后台分配一个userkey,使用这个userkey在redis中存储一波购物车数据
//2.做登录,登录后cookie中还存在userkey,所以UserINfo中还是原来的userkey,userId也会设置进去
BoundHashOperations opsUnLogin = redisTemplate.boundHashOps("cart:" + userInfo.getUserKey());
List<Cart> unLoginCarts = opsUnLogin.values();
BoundHashOperations opsLogin = redisTemplate.boundHashOps("cart:" + userInfo.getUserId());
//合并购物车
unLoginCarts.stream().forEach(cart -> {
if (opsLogin.hasKey(cart.getSkuId()+"")) {
//该购物项已经存在于登录的购物车中
Cart cart1 = (Cart) opsLogin.get(cart.getSkuId()+"");
cart1.setCount(cart.getCount() + cart1.getCount());
opsLogin.put(cart.getSkuId()+"", cart1);
//异步更新msql数据库
cartAsyncService.updateCartInfo(userInfo.getUserId()+"",cart.getSkuId()+"",cart1);
} else {
//购物项不存在于登录的购物车中,需要新增
cart.setUserId(userInfo.getUserId()+"");
opsLogin.put(cart.getSkuId()+"", cart);
//异步添加到mysql数据库
cartAsyncService.saveCartInfo(userInfo.getUserId()+"",cart);
}
});
//删除未登录的购物车
redisTemplate.delete("cart:" + userInfo.getUserKey());
//异步删除mysql数据库中数据
cartAsyncService.deleteCarts(unLoginCarts);
//设置所有已登录购物项的实时价格
List<Cart> values = opsLogin.values();
values.stream().forEach(cart -> {
if (redisTemplate.hasKey("cart:price:"+cart.getSkuId())) {
//有的话,则更新价格
String newPrice = (String) redisTemplate.opsForValue().get("cart:price:" + cart.getSkuId());
cart.setCurrentPrice(new BigDecimal(newPrice));
opsLogin.put(cart.getSkuId()+"",cart);
}
});
//返回合并后的购物车
return opsLogin.values();
}
5、购物车价格同步
商品加入购物车之后,商品的价格可能会被修改,会导致redis中购物车记录的价格和数据库中的价格不一致。需要进行同步,甚至是比价:
解决方案:
-
每次查询购物车从数据库查询当前价格(需要远程调用,影响系统并发能力)
-
商品修改后发送消息给购物车同步价格(推荐)
pms-service微服务价格修改后,mq发送消息给购物车cart-service,购物车获取消息后,怎么进行价格的同步?
因此,在添加购物车时需要:
redis中单独维护一个商品的价格,数据结构:{skuId: price}
redis中应该保存两份数据,一份购物车记录数据,一份sku最新价格数据