[TOC]
1 微服务网关
1.1 微服务网关的概述
网关是系统的唯一对外的入口,介于客户端和服务端之间的中间层,所有的外部请求都会先经过 网关这一层。网关处理非业务功能提供路由请求、鉴权、监控、缓存、限流等功能(类似所有请求都会经过的过滤器,类似演唱会过安检,检票员还可以监控有多少人进场,检查进场人员是否合法)(比如下单操作需要用户登陆,在Gateway层判断用户是否登陆),这样既提高业务灵活性又不缺安全性。
不同的微服务一般会有不同的网络地址,而外部客户端可能需要调用多个服务的接口才能完成一个业务需求,如果让客户端直接与各个微服务通信,会有以下的问题:
- 客户端会多次请求不同的微服务,增加了客户端的复杂性
- 存在跨域请求,在一定场景下处理相对复杂
- 认证复杂,每个服务都需要独立认证
- 难以重构,随着项目的迭代,可能需要重新划分微服务。例如,可能将多个服务合并成一个或者将一个服务拆分成多个。如果客户端直接与微服务通信,那么重构将会很难实施
- 某些微服务可能使用了防火墙 / 浏览器不友好的协议,直接访问会有一定的困难
以上这些问题可以借助网关解决。
网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过 网关这一层。也就是说,API 的实现方面更多的考虑业务逻辑,而安全、性能、监控可以交由 网关来做,这样既提高业务灵活性又不缺安全性,典型的架构图如图所示:
优点如下:
- 安全 ,只有网关系统对外进行暴露,微服务可以隐藏在内网,通过防火墙保护。
- 易于监控。可以在网关收集监控数据并将其推送到外部系统进行分析。
- 易于认证。可以在网关上进行认证,然后再将请求转发到后端的微服务,而无须在每个微服务中进行认证。
- 减少了客户端与各个微服务之间的交互次数
- 易于统一授权。
总结:微服务网关就是一个系统,通过暴露该微服务网关系统,方便我们进行相关的鉴权,安全控制,日志统一处理,易于监控的相关功能。
1.2 微服务网关技术
实现微服务网关的技术有很多,
- nginx Nginx (tengine x) 是一个高性能的HTTP和反向代理web服务器,同时也提供了IMAP/POP3/SMTP服务
- zuul ,Zuul 是 Netflix 出品的一个基于 JVM 路由和服务端的负载均衡器。
- spring-cloud-gateway, 是spring 出品的 基于spring 的网关项目,集成断路器,路径重写,性能比Zuul好。
我们使用gateway这个网关技术,无缝衔接到基于spring cloud的微服务开发中来。
gateway官网:
https://spring.io/projects/spring-cloud-gateway
2 网关系统使用
2.1 需求分析
由于我们开发的系统 有包括前台系统和后台系统,后台的系统 给管理员使用。那么也需要调用各种微服务,所以我们针对 系统管理搭建一个网关系统。分析如下:
2.2 搭建后台网关系统
2.2.1 搭建分析
创建网关微服务工程gateway,目录结构如下:
2.2.2 工程搭建
(1)引入依赖
pom.xml如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>legou-parent</artifactId>
<groupId>com.lxs</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>gateway</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
</dependencies>
</project>
(2)引导类
gateway/src/main/java/com/lxs/clound/GatewayApplication.java
package com.lxs.clound;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableCircuitBreaker
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
(3)配置文件
gateway/src/main/resources/bootstrap.yml:
spring:
application:
name: gateway
config-repo/gateway.yml
server:
port: 8062
ribbon:
ConnectTimeout: 250 # 连接超时时间(ms)
ReadTimeout: 2000 # 通信超时时间(ms)
OkToRetryOnAllOperations: true # 是否对所有操作重试
MaxAutoRetriesNextServer: 2 # 同一服务不同实例的重试次数
MaxAutoRetries: 1 # 同一实例的重试次数
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMillisecond: 60000 # 熔断超时时长:6000ms
2.3 跨域配置
有时候,我们需要对所有微服务跨域请求进行处理,则可以在gateway中进行跨域支持。添加如下代码:
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allow-credentials: true
allowed-origins: "*"
allowed-headers: "*"
allowed-methods: "*"
max-age: 3600
2.4 网关过滤配置
路由过滤器允许以某种方式修改传入的HTTP请求或传出的HTTP响应。 如上图,根据请求路径路由到不同微服务去,这块可以使用Gateway的路由过滤功能实现。
内置的过滤器工厂有22个实现类,包括 头部过滤器、路径过滤器、Hystrix 过滤器 、请求URL 变更过滤器,还有参数和状态码等其他类型的过滤器。根据过滤器工厂的用途来划分,可以分为以下几种:Header、Parameter、Path、Body、Status、Session、Redirect、Retry、RateLimiter和Hystrix。
2.4.1 路径匹配过滤配置
我们还可以根据请求路径实现对应的路由过滤操作,例如请求中以/brand/
路径开始的请求,都直接交给http://localhost:8062
服务处理,如下配置:
上图配置如下:
spring:
cloud:
gateway:
routes:
- id: item-service
uri: http://localhost:9005
predicates:
- Path=/api/item/**
测试请求http://localhost:8062/api/item/brand/list
,效果如下:
2.4.2 StripPrefix 过滤配置
很多时候也会有这么一种请求,用户请求路径是/api/item/brand/list
,而真实路径是/brand/list
,这时候我们需要去掉/api/item
才是真实路径,此时可以使用StripPrefix功能来实现路径的过滤操作,如下配置:
上图配置如下:
default-filters:
- StripPrefix=2
测试请求http://localhost:8001/api/item/brand/list
,效果如下:
2.4.3 LoadBalancerClient 路由过滤器(客户端负载均衡)
上面的路由配置每次都会将请求给指定的URL
处理,但如果在以后生产环境,并发量较大的时候,我们需要根据服务的名称判断来做负载均衡操作,可以使用LoadBalancerClientFilter
来实现负载均衡调用。LoadBalancerClientFilter
会作用在url以lb开头的路由,然后利用loadBalancer
来获取服务实例,构造目标requestUrl
,设置到GATEWAY_REQUEST_URL_ATTR
属性中,供NettyRoutingFilter
使用。
修改application.yml配置文件,代码如下:
上图配置如下:
- id: item-service
uri: lb://item-service
predicates:
- Path=/api/item/**
测试请求路径http://localhost:8001/api/item/brand/list
2.5 网关限流
网关可以做很多的事情,比如,限流,当我们的系统 被频繁的请求的时候,就有可能 将系统压垮,所以 为了解决这个问题,需要在每一个微服务中做限流操作,但是如果有了网关,那么就可以在网关系统做限流,因为所有的请求都需要先通过网关系统才能路由到微服务中。
2.5.1 思路分析
2.5.2 令牌桶算法
令牌桶算法是比较常见的限流算法之一,大概描述如下:
1)所有的请求在处理之前都需要拿到一个可用的令牌才会被处理;
2)根据限流大小,设置按照一定的速率往桶里添加令牌;
3)桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝;
4)请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除;
5)令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流
如下图:
这个算法的实现,有很多技术,Guaua是其中之一,redis客户端也有其实现。
2.5.3 漏桶算法
算法思想是:
- 水(请求)从上方倒入水桶,从水桶下方流出(被处理);
- 来不及流出的水存在水桶中(缓冲),以固定速率流出;
- 水桶满后水溢出(丢弃)。
- 这个算法的核心是:缓存请求、匀速处理、多余的请求直接丢弃。
两种算法的区别
两者主要区别在于“漏桶算法”能够强行限制数据的传输速率,而“令牌桶算法”在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在“令牌桶算法”中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,所以它适合于具有突发特性的流量。
2.5.4 网关限流
spring cloud gateway 默认使用redis的RateLimter限流算法来实现。所以我们要使用首先需要引入redis的依赖
(1)引入redis依赖
在gateway的pom.xml中引入redis的依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<version>2.1.3.RELEASE</version>
</dependency>
(2)定义KeyResolver
在Applicatioin引导类中添加如下代码,KeyResolver用于计算某一个类型的限流的KEY也就是说,可以通过KeyResolver来指定限流的Key。
我们可以根据IP来限流,比如每个IP每秒钟只能请求一次,在GatewayWebApplication定义key的获取,获取客户端IP,将IP作为key,如下代码:
/***
* IP限流
* @return
*/
@Bean(name="ipKeyResolver")
public KeyResolver userKeyResolver() {
return new KeyResolver() {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
//获取远程客户端IP
String hostName = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
System.out.println("hostName:"+hostName);
return Mono.just(hostName);
}
};
}
(3)修改application.yml中配置项,指定限制流量的配置以及REDIS的配置,如图
修改如下图:
完成配置代码如下:
config-repo/gateway.yml
server:
port: 8062
spring:
redis:
host: 192.168.220.110
port: 6379
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allow-credentials: true
allowed-origins: "*"
allowed-headers: "*"
allowed-methods: "*"
max-age: 3600
routes:
- id: security-service
uri: lb://security-service
predicates:
- Path=/api/security/**
- id: admin-service
uri: lb://admin-service
predicates:
- Path=/api/admin/**
- id: route-service
uri: lb://route-service
predicates:
- Path=/api/route/**
- id: item-service
uri: lb://item-service
predicates:
- Path=/api/item/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@ipKeyResolver}"
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 4
- id: search-service
uri: lb://search-service
predicates:
- Path=/api/search/**
default-filters:
- StripPrefix=2
ribbon:
ConnectTimeout: 250 # 连接超时时间(ms)
ReadTimeout: 2000 # 通信超时时间(ms)
OkToRetryOnAllOperations: true # 是否对所有操作重试
MaxAutoRetriesNextServer: 2 # 同一服务不同实例的重试次数
MaxAutoRetries: 1 # 同一实例的重试次数
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMillisecond: 60000 # 熔断超时时长:6000ms
解释:
redis-rate-limiter.replenishRate
是您希望允许用户每秒执行多少请求,而不会丢弃任何请求。这是令牌桶填充的速率
redis-rate-limiter.burstCapacity
是指令牌桶的容量,允许在一秒钟内完成的最大请求数,将此值设置为零将阻止所有请求。
key-resolver: "#{@ipKeyResolver}" 用于通过SPEL表达式来指定使用哪一个KeyResolver.
如上配置:
表示 一秒内,允许 一个请求通过,令牌桶的填充速率也是一秒钟添加一个令牌。
最大突发状况 也只允许 一秒内有一次请求,可以根据业务来调整 。
多次请求会发生如下情况
3 RBAC权限数据管理
3.1 RBAC简介
RBAC(基于角色的权限控制 role base access control)是一种设计模式,是用来设计和管理权限相关数据的一种模型
RBAC权限数据的管理,都是重复的CRUD的操作,这里我们就不再重复的从0到1开发,我们只是把模块中需要注意的逻辑和代码讲一下,重复代码不再复述
RBAC模式:基于角色的访问控制,表结构如下:
3.2 需求分析
3.2.1 用户管理
用户列表
用户添加修改
3.2.2 角色管理
角色列表
角色修改
3.2.3 菜单管理
菜单列表
菜单修改
菜单不同于单表管理,是一个树状表结构管理,这里我们重点关注菜单(树状表结构的数据)的增删改查
3.3 导入权限数据管理工程
这里我们直接导入素材中的权限管理工程,因为业务比较复杂,不再从0到1开发,大家重点关注重点模块的业务逻辑实现即可
3.4 实体类
package com.lxs.legou.security.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.util.Date;
@Data
@TableName("user_")
public class User extends BaseEntity {
@TableField("user_name_")
private String userName; //登录名
@TableField("real_name_")
private String realName; //真实姓名
@TableField("password_")
private String password;
@TableField("salt")
private String salt; //加密密码的盐
@TableField("sex_")
private String sex;
@TableField("tel_")
private String tel;
@TableField("email_")
private String email;
@TableField("desc_")
private String desc;
@TableField("lock_")
private Boolean lock; //是否锁定
@TableField("birthday_")
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd")
@DateTimeFormat(pattern="yyyy-MM-dd")
private Date birthday;
@TableField("principal_")
private Boolean principal; //是否为部门负责人,用于"常用语:直接上级"
@TableField("dept_id_")
private Long deptId; //部门
@TableField("post_id_")
private Long postId; //岗位
@TableField(exist = false)
private String deptName; //部门名称,用于列表显示
@TableField(exist = false)
private Long[] roleIds; //瞬时属性,用户的角色列表,如:[1,3,4,5]
@TableField(exist = false)
private String postName; //部门名称,用于列表显示
public String credentialsSalt() {
return userName + salt;
}
}
package com.lxs.legou.security.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.lxs.legou.core.po.BaseEntity;
import lombok.Data;
@Data
@TableName("role_")
public class Role extends BaseEntity {
@TableField("name_")
private String name;
@TableField("title_")
private String title;
@TableField("desc_")
private String desc;
@TableField(exist = false)
private Long[] userIds; //瞬时属性,角色的用户列表,如:[1,3,4,5]
}
package com.lxs.legou.security.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.lxs.legou.core.po.BaseTreeEntity;
import lombok.Data;
@Data
@TableName("menu_")
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class Menu extends BaseTreeEntity {
@TableField("path_")
private String path;
@TableField("name_")
private String name;
@TableField("component_")
private String component;
@TableField("hide_in_menu_")
private Boolean hideInMenu = false; //设为true后在左侧菜单不会显示该页面选项
@TableField("not_cache_")
private Boolean notCache = false; //设为true后页面不会缓存
@TableField("icon_")
private String icon; //该页面在左侧菜单、面包屑和标签导航处显示的图标,如果是自定义图标,需要在图标名称前加下划线'_'
@TableField(exist = false)
private Long roleId; //查询条件,拥有查询角色的菜单
@TableField(exist = false)
private Long userId; //查询条件,拥有查询用户的菜单
@TableField(exist = false)
private boolean selected; //角色选择菜单,选中角色已有的菜单
}
这里需要重点注意Menu实体类,因为是树状结构的数据需要继承BaseTreeEntity,通过parent_id进行自身的关联,BaseTreeEntity代码
package com.lxs.legou.core.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
@Data
@JsonIgnoreProperties(value = {"handler"}) //避免懒加载,json转换报错
public class BaseTreeEntity extends BaseEntity {
@TableField("order_")
private Integer order; //排序字段
@TableField("parent_id_")
private Long parentId; //父节点id
@TableField("title_")
private String title; //节点名称
@TableField("expand_")
private Boolean expand = false; //是否展开节点
}
3.5 DAO和映射文件
参考具体导入的代码,实现具体用户角色,菜单的CRUD操作,因为代码较多这里不再转帖
3.6 Service
package com.lxs.legou.security.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.security.po.Role;
import com.lxs.legou.security.po.User;
import java.util.List;
public interface IUserService extends ICrudService<User> {
/**
* 根据用户id查询角色
* @param id
* @return
*/
public List<Role> selectRoleByUser(Long id);
/**
* 根据用户名,查询用户个数
* @param userName
* @return
*/
public Integer findCountByUserName(String userName);
/**
* 根据用户名查询用户
* @param userName
* @return
*/
public User getUserByUserName(String userName);
}
package com.lxs.legou.security.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.security.po.Role;
import com.lxs.legou.security.po.User;
import java.util.List;
public interface IRoleService extends ICrudService<Role> {
/**
* 查询角色的用户
* @param id
* @return
*/
public List<User> selectUserByRole(Long id);
}
package com.lxs.legou.security.service;
import com.lxs.legou.core.service.ICrudService;
import com.lxs.legou.security.po.Menu;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public interface IMenuService extends ICrudService<Menu> {
/**
* 查询用户的菜单
* @param userId
* @return
*/
public List<Menu> listByUser(Long userId);
/**
* 查询所有菜单,选中角色已有的菜单
* @param roleId
* @return
*/
public List<Menu> listChecked(Long roleId) ;
/**
* 关联角色和菜单
* @param roleId
* @param menuIds
*/
public void doAssignMenu2Role(Long roleId, Long[] menuIds);
}
具体实现类参考项目代码
3.7 Controller
package com.lxs.legou.security.controller;
import com.lxs.legou.core.controller.BaseController;
import com.lxs.legou.security.po.Role;
import com.lxs.legou.security.po.User;
import com.lxs.legou.security.service.IRoleService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/role")
public class RoleController extends BaseController<IRoleService, Role> {
@Override
public void afterEdit(Role entity) {
//生成用户列表, 如:1,3,4
List<User> users = service.selectUserByRole(entity.getId());
Long[] ids = new Long[users.size()];
for (int i=0; i< users.size(); i++) {
ids[i] = users.get(i).getId();
}
entity.setUserIds(ids);
}
}
其他UserController和MenuController参考具体代码
3.8 网关配置
- id: security-service
uri: lb://security-service
predicates:
- Path=/api/security/**
3.8 前端代码
3.8.1 菜单管理
因为菜单管理不同于单表操作,展示的树状结构的数据显示,这里关注下具体实现
完整代码,参考具体前端代码
3.8.2 用户角色管理
用户列表
用户修改
选择角色的组件
<template>
<Select :value="currentValue" @on-change="handleInput" multiple >
<Option v-for="item in roleList" :value="item.id" :key="item.id">{
{ item.name }}</Option>
</Select>
</template>
<script>
import instance from '@/libs/api/index'
export default {
name: 'selectRoles',
data () {
return {
roleList: []
}
},
computed: {
currentValue: function () {
return this.value
}
},
props: ['value'], // 接收一个 value prop
methods: {
handleInput (value) {
this.$emit('input', value) // 触发 input 事件,并传入新值
}
},
mounted () {
instance.post(`/security/role/list`).then(response => {
this.roleList = response.data
}).catch(error => {
console.log(error)
})
}
}
</script>
具体角色管理和用户管理相似,这里不再复述
注意:前端代码比较复杂可以拷贝,做到会修改即可,有兴趣的同学可以拿出时间从0到1编码
4 组织机构管理
组织机构模块主要包括部门,职位,和字典的管理,其中部门跟菜单相似是树状结构数据,跟权限数据模块管理类似,这里时间问题,不再重复讲解,大家导入提供的组织机构管理工程即可,后续可以使用,能够修改即可
网关路由配置
- id: admin-service
uri: lb://admin-service
predicates:
- Path=/api/admin/**
4.1 表结构
4.2 功能介绍
岗位管理
部门管理
权限数据和组织机构数据管理,不涉及新的技术,但是业务逻辑比较复杂,这里我们导入即可,不再重复实现crud操作了,有时间大家根据标准代码,自己写一遍
4.3 代码实现
具体参考标准代码,因为跟之前业务逻辑基本相同,这里不再重复
5 单点登录(SSO)分析
5.1.什么是SSO
在企业发展初期,企业使用的系统很少,通常一个或者两个,每个系统都有自己的登录模块,运营人员每天用自己的账号登录,很方便。但随着企业的发展,用到的系统随之增多,运营人员在操作不同的系统时,需要多次登录,而且每个系统的账号都不一样,这对于运营人员来说,很不方便。于是,就想到是不是可以在一个系统登录,其他系统就不用登录了呢?这就是单点登录要解决的问题。单点登录英文全称Single Sign On,简称就是SSO。它的解释是:在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。
5.2.普通认证机制
如上图所示,我们在浏览器(Browser)中访问一个应用,这个应用需要登录,我们填写完用户名和密码后,完成登录认证。这时,我们在这个用户的session中标记登录状态为yes(已登录),同时在浏览器(Browser)中写入Cookie,这个Cookie是这个用户的唯一标识。下次我们再访问这个应用的时候,请求中会带上这个Cookie,服务端会根据这个Cookie找到对应的session,通过session来判断这个用户是否登录。如果不做特殊配置,这个Cookie的名字叫做jsessionid,值在服务端(server)是唯一的。
5.3.同域名下的SSO
一个企业一般情况下只有一个域名,通过二级域名区分不同的系统。比如我们有个域名叫做:a.com,同时有两个业务系统分别为:app1.a.com和app2.a.com。我们要做单点登录(SSO),需要一个登录
系统,叫做:sso.a.com。
我们只要在sso.a.com登录,app1.a.com和app2.a.com就也登录了。通过上面的登陆认证机制,我们可以知道,在sso.a.com中登录了,其实是在sso.a.com的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.a.com下写入了Cookie。那么我们怎么才能让app1.a.com和app2.a.com登录呢?这里有两个问题:
- Cookie是不能跨域的,我们Cookie的domain属性是sso.a.com,在给app1.a.com和app2.a.com发送请求是带不上的。
- sso、app1和app2是不同的应用,它们的session存在自己的应用内,是不共享的
那么我们如何解决这两个问题呢?针对第一个问题,sso登录以后,可以将Cookie的域设置为顶域,即.a.com,这样所有子域的系统都可以访问到顶域的Cookie。我们在设置Cookie时,只能设置顶域和自己的域,不能设置其他的域。比如:我们不能在自己的系统中给baidu.com的域设置Cookie。
Cookie的问题解决了,我们再来看看session的问题。我们在sso系统登录了,这时再访问app1,Cookie也带到了app1的服务端(Server),app1的服务端怎么找到这个Cookie对应的Session呢?这里就要把3个系统的Session共享,如图所示。共享Session的解决方案有很多,例如:SpringSession。这样第2个问题也解决了。
5.4.基于token的认证
最近几年由于单页app、web APIs等的兴起,基于token的身份验证开始流行。当我们谈到利用token进行认证,我们一般说的就是利用JSON Web Tokens(JWTs)进行认证。虽然有不同的方式来实现token,事实上,JWTs 已成为标准。因此在本文中将互换token与JWTs。
基于token的身份验证是无状态的,服务器不需要记录哪些用户已经登录或者哪些JWTs已经处理。每个发送到服务器的请求都会带上一个token,服务器利用这个token检查确认请求的真实性。
这里可以把token理解成一张演唱会的门票。服务器(演唱会主办方)每次只需要检查你这张门票的有效性,不需要知道你这张门票是在哪里买的,从谁买的,什么时候买的等等。不同等级的门票可以坐的位置不同,同样的,权限不同的用户可以进行的操作也不同。
6 JWT讲解
6.1 需求分析
我们之前已经搭建过了网关,使用网关在网关系统中比较适合进行权限校验。
那么我们可以采用JWT的方式来实现鉴权校验。
6.2 什么是JWT
JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递可靠的信息。
6.3 JWT的构成
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
头部(Header)
头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。
{
"typ":"JWT","alg":"HS256"}
在头部指明了签名算法是HS256算法。 我们进行BASE64编码http://base64.xpcha.com/,编码后的字符串如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
小知识:Base64是一种基于64个可打印字符来表示二进制数据的表示方法。由于2的6次方等于64,所以每6个比特为一个单元,对应某个可打印字符。三个字节有24个比特,对应于4个Base64单元,即3个字节需要用4个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用它们可以非常方便的完成基于 BASE64 的编码和解码
载荷(payload)
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
(1)标准中注册的声明(建议但不强制使用)
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
(2)公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
(3)私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
这个指的就是自定义的claim。比如下面面结构举例中的admin和name都属于自定的claim。这些claim跟JWT标准规定的claim区别在于:JWT规定的claim,JWT的接收方在拿到JWT之后,都知道怎么对这些标准的claim进行验证(还不知道是否能够验证);而private claims不会验证,除非明确告诉接收方要对这些claim进行验证以及规则才行。
定义一个payload:
{"sub":"1234567890","name":"John Doe","admin":true}
然后将其进行base64加密,得到Jwt的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
签证(signature)
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
header (base64后的)
payload (base64后的)
secret
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式进行加盐secret组合加密,然后就构成了jwt的第三部分。
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
将这三部分用.连接成一个完整的字符串,构成了最终的jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
6.4 JJWT的介绍和使用
JJWT是一个提供端到端的JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。
官方文档:
6.4.1 创建TOKEN
(1)依赖引入
在gateway项目中的pom.xml中添加依赖:
<!--鉴权-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
(2)创建测试
在gateway的/test/java下创建测试类,并设置测试方法
public class TestJWT {
@Test
public void createJWT() {
JwtBuilder builder = Jwts.builder()
.setId("12356") //设置唯一编号
.setSubject("password123") //主题
.setIssuedAt(new Date()) //签发时间
.signWith(SignatureAlgorithm.HS256, "123456"); //签名,采用HS256算法加密,秘钥
System.out.println(builder.compact());
}
}
运行打印结果:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiLllK_kuIDnmoTmoIfor4YiLCJpc3MiOiLpooHlj5HogIUiLCJzdWIiOiLkuLvpopgiLCJleHAiOjE2MDIzMjIwMDEsIm15Y2l0eSI6ImJlaWppbmciLCJteWFkZHJlc3MiOiJjbiJ9.c7UxJaQVxulwVqvIZLP_8RxSTbKOyixcU6mgMFaYrw0
再次运行,会发现每次运行的结果是不一样的,因为我们的载荷中包含了时间。
6.4.2 TOKEN解析
我们刚才已经创建了token ,在web应用中这个操作是由服务端进行然后发给客户端,客户端在下次向服务端发送请求时需要携带这个token(这就好像是拿着一张门票一样),那服务端接到这个token 应该解析出token中的信息(例如用户id),根据这些信息查询数据库返回相应的结果。
@Test
public void testParseJwt() {
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM1NiIsInN1YiI6InBhc3N3b3JkMTIzIiwiaWF0IjoxNjA1NzY4NzQ4LCJyb2xlIjoiQURNSU4iLCJteWNpdHkiOiJoYW5nemhvdSIsIm15YWRkcmVzcyI6ImNuIn0.598OCjR-ItxRat7_VvWklUbIgGehJuEcXu21EEub8vs";
Claims claims = Jwts.parser()
.setSigningKey("123456")
.parseClaimsJws(jwt)
.getBody();
System.out.println(claims);
System.out.println(claims.getSubject());
}
运行打印效果:
{id=12356, sub=password123, iat=1562062287}
试着将token或签名秘钥篡改一下,会发现运行时就会报错,所以解析token也就是验证token.
6.4.3 设置过期时间
有很多时候,我们并不希望签发的token是永久生效的,所以我们可以为token添加一个过期时间。
6.4.3.1 token过期设置
@Test
public void createJWT() {
long l1 = System.currentTimeMillis(); //当前时间戳
long l2 = l1 + 20000; //过期时间错
JwtBuilder builder = Jwts.builder()
.setId("12356") //设置唯一编号
.setSubject("password123") //主题
.setIssuedAt(new Date()) //签发时间
.setExpiration(new Date(l2))
.signWith(SignatureAlgorithm.HS256, "lxs"); //签名,采用HS256算法加密,秘钥
System.out.println(builder.compact());
}
解释:
.setExpiration(date)//用于设置过期时间 ,参数为Date类型数据
运行,打印效果如下:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1NjIwNjI5MjUsImV4cCI6MTU2MjA2MjkyNX0._vs4METaPkCza52LuN0-2NGGWIIO7v51xt40DHY1U1Q
6.4.3.2 解析TOKEN
/***
* 解析Jwt令牌数据
*/
@Test
public void testParseJwt(){
String compactJwt="eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiLlsI_nmb0iLCJpYXQiOjE1NjIwNjI5MjUsImV4cCI6MTU2MjA2MjkyNX0._vs4METaPkCza52LuN0-2NGGWIIO7v51xt40DHY1U1Q";
Claims claims = Jwts.parser().
setSigningKey("lxs").
parseClaimsJws(compactJwt).
getBody();
System.out.println(claims);
}
打印效果:
当前时间超过过期时间,则会报错。
6.4.4 自定义claims
我们刚才的例子只是存储了id和subject两个信息,如果你想存储更多的信息(例如角色)可以定义自定义claims。
创建测试类,并设置测试方法:
创建token:
@Test
public void createJWT() {
long l1 = System.currentTimeMillis(); //当前时间戳
long l2 = l1 + 20000; //过期时间错
JwtBuilder builder = Jwts.builder()
.setId("12356") //设置唯一编号
.setSubject("password123") //主题
.setIssuedAt(new Date()) //签发时间
// .setExpiration(new Date(l2))
.claim("role", "ADMIN")
.signWith(SignatureAlgorithm.HS256, "lxs"); //签名,采用HS256算法加密,秘钥
//自定义载荷
Map<String, Object> map = new HashMap<>();
map.put("myaddress", "cn");
map.put("mycity", "hangzhou");
builder.addClaims(map);
System.out.println(builder.compact());
}
运行打印效果:
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiLllK_kuIDnmoTmoIfor4YiLCJpc3MiOiLpooHlj5HogIUiLCJzdWIiOiLkuLvpopgiLCJleHAiOjE2MDIzMjIwMDEsIm15Y2l0eSI6ImJlaWppbmciLCJteWFkZHJlc3MiOiJjbiJ9.c7UxJaQVxulwVqvIZLP_8RxSTbKOyixcU6mgMFaYrw0
解析TOKEN:
@Test
public void testParseJwt() {
String jwt = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMjM1NiIsInN1YiI6InBhc3N3b3JkMTIzIiwiaWF0IjoxNjA1NzY4NzQ4LCJyb2xlIjoiQURNSU4iLCJteWNpdHkiOiJoYW5nemhvdSIsIm15YWRkcmVzcyI6ImNuIn0.598OCjR-ItxRat7_VvWklUbIgGehJuEcXu21EEub8vs";
Claims claims = Jwts.parser()
.setSigningKey("lxs")
.parseClaimsJws(jwt)
.getBody();
System.out.println(claims);
System.out.println(claims.get("role"));
System.out.println(claims.getSubject());
}
运行效果:
6.5 鉴权处理
6.5.1 思路分析
1.用户通过访问微服务网关调用微服务,同时携带头文件信息
2.在微服务网关这里进行拦截,拦截后获取用户要访问的路径
3.识别用户访问的路径是否需要登录,如果需要,识别用户的身份是否能访问该路径[这里可以基于数据库设计一套权限]
4.如果需要权限访问,用户已经登录,则放行
5.如果需要权限访问,且用户未登录,则提示用户需要登录
6.用户通过网关访问用户微服务,进行登录验证
7.验证通过后,用户微服务会颁发一个令牌给网关,网关会将用户信息封装到头文件中,并响应用户
8.用户下次访问,携带头文件中的令牌信息即可识别是否登录
6.5.2用户登录签发TOKEN
(1)生成令牌工具类
在gateway中创建类JwtUtil,主要辅助生成Jwt令牌信息,代码如下:
gateway/src/main/java/com/lxs/clound/util/JwtUtil.java
package com.lxs.clound.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
public class JwtUtil {
//有效期为
public static final Long JWT_TTL = 3600000L;// 60 * 60 *1000 一个小时
//Jwt令牌信息
public static final String JWT_KEY = "lxs";
/**
* 生成令牌
* @param id
* @param subject
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String subject, Long ttlMillis) {
//指定算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//当前系统时间
long nowMillis = System.currentTimeMillis();
//令牌签发时间
Date now = new Date(nowMillis);
//如果令牌有效期为null,则默认设置有效期1小时
if (ttlMillis == null) {
ttlMillis = JwtUtil.JWT_TTL;
}
//令牌过期时间设置
long expMillis = nowMillis + ttlMillis;
Date expDate = new Date(expMillis);
//生成秘钥
SecretKey secretKey = generalKey();
//封装Jwt令牌信息
JwtBuilder builder = Jwts.builder()
.setId(id) //唯一的ID
.setSubject(subject) // 主题 可以是JSON数据
.setIssuer("admin") // 签发者
.setIssuedAt(now) // 签发时间
.signWith(signatureAlgorithm, secretKey) // 签名算法以及密匙
.setExpiration(expDate); // 设置过期时间
return builder.compact();
}
/**
* 生成加密 secretKey
*
* @return
*/
public static SecretKey generalKey() {
byte[] encodedKey = Base64.getEncoder().encode(JwtUtil.JWT_KEY.getBytes());
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
/**
* 解析令牌数据
*
* @param jwt
* @return
* @throws Exception
*/
public static Claims parseJWT(String jwt) throws Exception {
SecretKey secretKey = generalKey();
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
}
public static void main(String[] args) {
String jwt = JwtUtil.createJWT("weiyibiaoshi", "aaaaaa", null);
System.out.println(jwt);
try {
Claims claims = JwtUtil.parseJWT(jwt);
System.out.println(claims);
} catch (Exception e) {
e.printStackTrace();
}
}
}
gateway/src/main/java/com/lxs/clound/util/BCrypt.java
用于BCrypt加密解密的工具类(拷贝即可)
(2) 通过Feign调用用户微服务,实现具体登录
gateway/src/main/java/com/lxs/clound/client/UserClient.java
package com.lxs.clound.client;
import com.lxs.legou.security.api.UserApi;
import com.lxs.legou.security.po.Role;
import com.lxs.legou.security.po.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@FeignClient(name = "security-service", fallback = UserClient.UserClientFallback.class)
public interface UserClient extends UserApi {
@Component
@RequestMapping("/fallback") //这个可以避免容器中requestMapping重复
class UserClientFallback implements UserClient {
private static final Logger LOGGER = LoggerFactory.getLogger(UserClientFallback.class);
@Override
public User getByUserName(String userName) {
LOGGER.info("异常发生,进入fallback方法");
return null;
}
@Override
public List<Role> selectRolesByUserId(Long id) {
LOGGER.info("异常发生,进入fallback方法");
return null;
}
}
}
引入legou-security-instance依赖
<dependency>
<groupId>com.lxs</groupId>
<artifactId>legou-security-instance</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</exclusion>
</exclusions>
</dependency>
(3) 用户登录成功 则 签发TOKEN,修改登录的方法:
代码如下:
package com.lxs.clound.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lxs.clound.client.UserClient;
import com.lxs.clound.util.BCrypt;
import com.lxs.clound.util.JwtUtil;
import com.lxs.legou.security.po.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.*;
@RequestMapping("/user")
@RestController
@CrossOrigin
public class UserController {
@Autowired
private UserClient userClient;
@Autowired
private ObjectMapper om;
@RequestMapping("/login")
public ResponseEntity login(String username,String password) throws Exception {
//1.从数据库中查询用户名对应的用户的对象
User user = userClient.getByUserName(username);
if (user == null) {
//2.判断用户是否为空 为空返回数据
return new ResponseEntity("用户名密码错误", HttpStatus.UNAUTHORIZED);
}
//3如果不为空 判断 密码是否正确 正确则登录成功
if(BCrypt.checkpw(password, user.getPassword())){
//成功
Map<String,Object> info = new HashMap<String,Object>();
info.put("role","USER");
info.put("success","SUCCESS");
info.put("username",username);
//1.生成令牌
String jwt = JwtUtil.createJWT(UUID.randomUUID().toString(), om.writeValueAsString(info), null);
return ResponseEntity.ok(jwt);
}else{
//失败
return new ResponseEntity("用户名密码错误", HttpStatus.UNAUTHORIZED);
}
}
}
执行测试后的效果
6.5.3 自定义全局过滤器
创建 过滤器类,如图所示:
AuthorizeFilter代码如下:
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
private static final String AUTHORIZE_TOKEN = "Authorization";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1: 如果访问登录等放行
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
if (request.getURI().getPath().startsWith("/user/login")) {
return chain.filter(exchange);
}
//2: 获得令牌
String token = request.getHeaders().getFirst(AUTHORIZE_TOKEN);
if (StringUtils.isEmpty(token)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
//3:校验令牌
try {
Claims claims = JwtUtil.parseJWT(token);
//对解析的用户的角色判断,不同角色访问相应角色的微服务
} catch (Exception e) {
e.printStackTrace();
//解析失败
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.setComplete();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
//返回值越小,在过滤器链中越有限执行
return 0;
}
}
测试访问http://localhost:8062/user/login?username=admin&password=admin
,效果如下:
测试访问http://localhost:8062/api/item/brand/list
,效果如下:
参考官方手册: