昨天的课程中,我们实现了登录相关的几个功能,也就是给用户授权。接下来,用户访问我们的系统,我们还需要根据用户的身份,判断是否有权限访问微服务资源,就是鉴权。
大部分的微服务都必须做这样的权限判断,但是如果在每个微服务单独做权限控制,每个微服务上的权限代码就会有重复,如何更优雅的完成权限控制呢?
我们可以在整个服务的入口完成服务的权限控制,这样微服务中就无需再做了,如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hWD46ZUi-1597970259332)(assets/1554643791047.png)]
接下来,我们在Zuul编写拦截器,对用户的token进行校验,完成初步的权限判断。
权限控制,一般有粗粒度、细粒度控制之分,但不管哪种,前提是用户必须先登录。知道访问者是谁,才能知道这个人具备怎样的权限,可以访问那些服务资源(也就是微服务接口)。
因此,权限控制的基本流程是这样:
一般权限信息会存储到数据库,会对应角色表和权限表:
一个角色一般会有多个权限,一个权限也可以属于多个用户,属于多对多关系。根据角色可以查询到对应的所有权限,再根据权限判断是否可以访问当前资源即可。
在我们的功能中,因为还没有写权限功能,所以暂时只有一个角色,就是普通用户,可以访问的是商品及分类品牌等的查询功能,以及自己的信息。以后编写权限服务时,再补充相关业务。
权限控制的第一部分,就是获取cookie,并解析jwt,那么肯定需要公钥。我们在ly-api-gateway
中配置公钥信息,并在服务启动时加载。
首先引入所需要的依赖:
<dependency>
<groupId>com.leyougroupId>
<artifactId>ly-commonartifactId>
<version>1.0.0-SNAPSHOTversion>
dependency>
然后编写属性文件:
ly:
jwt:
pubKeyPath: D:/heima/rsa/id_rsa.pub # 公钥地址
user:
cookieName: LY_TOKEN # cookie名称
编写属性类,读取公钥:
@Data
@Slf4j
@ConfigurationProperties(prefix = "ly.jwt")
public class JwtProperties implements InitializingBean {
/**
* 公钥地址
*/
private String pubKeyPath;
private PublicKey publicKey;
/**
* 用户token相关属性
*/
private UserTokenProperties user = new UserTokenProperties();
@Data
public class UserTokenProperties {
/**
* 存放token的cookie名称
*/
private String cookieName;
}
@Override
public void afterPropertiesSet() throws Exception {
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
log.error("初始化公钥失败!", e);
throw new RuntimeException(e);
}
}
}
有了公钥,就可以编写权限控制逻辑了,权限验证通过,放行到微服务;不通过,则拦截并返回401给用户。因此拦截的逻辑需要在请求被路由之前执行,你能想到用什么实现吗?
没错,就是ZuulFilter。
ZuulFilter是Zuul的过滤器,其中pre类型的过滤器会在路由之前执行,刚好符合我们的需求。接下来,我们自定义pre类型的过滤器,并在过滤器中完成权限校验逻辑。
基本逻辑:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Uszf1scU-1597970259340)(assets/1554647164661.png)]
package com.leyou.gateway.filters;
import com.leyou.common.auth.entity.Payload;
import com.leyou.common.auth.entity.UserInfo;
import com.leyou.common.auth.utils.JwtUtils;
import com.leyou.common.utils.CookieUtils;
import com.leyou.gateway.config.JwtProperties;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Component
@EnableConfigurationProperties(JwtProperties.class)
public class AuthFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProp;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return FilterConstants.FORM_BODY_WRAPPER_FILTER_ORDER - 1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
// 获取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 获取request
HttpServletRequest request = ctx.getRequest();
// 获取token
String token = CookieUtils.getCookieValue(request, jwtProp.getUser().getCookieName());
// 校验
try {
// 解析token
Payload<UserInfo> payload = JwtUtils.getInfoFromToken(token, jwtProp.getPublicKey(), UserInfo.class);
// 解析没有问题,获取用户
UserInfo user = payload.getUserInfo();
// 获取用户角色,查询权限
String role = user.getRole();
// 获取当前资源路径
String path = request.getRequestURI();
String method = request.getMethod();
// TODO 判断权限,此处暂时空置,等待权限服务完成后补充
log.info("【网关】用户{},角色{}。访问服务{} : {},", user.getUsername(), role, method, path);
} catch (Exception e) {
// 校验出现异常,返回403
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(403);
log.error("非法访问,未登录,地址:{}", request.getRemoteHost(), e );
}
return null;
}
}
登录状态时,访问商品查询接口:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-15s8sGsu-1597970259344)(assets/1554646907505.png)]
没有问题,可以访问。
退出登录,再次访问:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IwfqDxeF-1597970259352)(assets/1554646947375.png)]
证明拦截器生效了!
此时我们尝试再次登录:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cPKHoV8s-1597970259356)(assets/1554647020757.png)]
登录接口也被拦截器拦截了!!!
要注意,并不是所有的路径我们都需要拦截,例如:
登录校验接口:/auth/login
注册接口:/user/register
数据校验接口:/user/check/
发送验证码接口:/user/code
搜索接口:/search/**
另外,跟后台管理相关的接口,因为我们没有做登录和权限,因此暂时都放行,但是生产环境中要做登录校验:
/item/**
所以,我们需要在拦截时,配置一个白名单,如果在名单内,则不进行拦截。
在application.yaml
中添加规则:
ly:
filter:
allowPaths:
- /api/auth/login
- /api/search
- /api/user/register
- /api/user/check
- /api/user/code
- /api/item
然后读取这些属性:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OIYMyl1X-1597970259366)(assets/1554647210954.png)]
内容:
@ConfigurationProperties(prefix = "ly.filter")
public class FilterProperties {
private List<String> allowPaths;
public List<String> getAllowPaths() {
return allowPaths;
}
public void setAllowPaths(List<String> allowPaths) {
this.allowPaths = allowPaths;
}
}
在过滤器中的shouldFilter
方法中添加判断逻辑:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vo4ZoLjP-1597970259369)(assets\1560013501401.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F9LPhoN1-1597970259371)(assets/1527558787803.png)]
代码:
package com.leyou.gateway.filters;
import com.leyou.common.auth.entity.Payload;
import com.leyou.common.auth.entity.UserInfo;
import com.leyou.common.auth.utils.JwtUtils;
import com.leyou.common.utils.CookieUtils;
import com.leyou.gateway.config.FilterProperties;
import com.leyou.gateway.config.JwtProperties;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@Component
@EnableConfigurationProperties({JwtProperties.class, FilterProperties.class})
public class AuthFilter extends ZuulFilter {
@Autowired
private JwtProperties jwtProp;
@Autowired
private FilterProperties filterProp;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
@Override
public int filterOrder() {
return FilterConstants.FORM_BODY_WRAPPER_FILTER_ORDER - 1;
}
@Override
public boolean shouldFilter() {
// 获取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 获取request
HttpServletRequest req = ctx.getRequest();
// 获取路径
String requestURI = req.getRequestURI();
// 判断白名单
return !isAllowPath(requestURI);
}
private boolean isAllowPath(String requestURI) {
// 定义一个标记
boolean flag = false;
// 遍历允许访问的路径
for (String path : this.filterProp.getAllowPaths()) {
// 然后判断是否是符合
if(requestURI.startsWith(path)){
flag = true;
break;
}
}
return flag;
}
@Override
public Object run() throws ZuulException {
// 获取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 获取request
HttpServletRequest request = ctx.getRequest();
// 获取token
String token = CookieUtils.getCookieValue(request, jwtProp.getUser().getCookieName());
// 校验
try {
// 解析token
Payload<UserInfo> payload = JwtUtils.getInfoFromToken(token, jwtProp.getPublicKey(), UserInfo.class);
// 解析没有问题,获取用户
UserInfo user = payload.getUserInfo();
// 获取用户角色,查询权限
String role = user.getRole();
// 获取当前资源路径
String path = request.getRequestURI();
String method = request.getMethod();
// TODO 判断权限,此处暂时空置,等待权限服务完成后补充
log.info("【网关】用户{},角色{}。访问服务{} : {},", user.getUsername(), role, method, path);
} catch (Exception e) {
// 校验出现异常,返回403
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(403);
log.error("非法访问,未登录,地址:{}", request.getRemoteHost(), e );
}
return null;
}
}
用户访问我们的微服务,都需要经过网关作为请求入口,网关对用户身份进行验证,从而保证微服务的安全。但是,大家有没有思考过这样一个问题:
如果你的微服务地址不小心暴露了呢?
一旦微服务地址暴露,用户就可以绕过网关,直接请求微服务,那么我们之前做的一切权限控制就白费了!
因此,我们的每个微服务都需要对调用者的身份进行认证,如果不是有效的身份,则应该阻止访问。
合法的调用者身份,其实就是其它微服务,还有网关。我们首先需要把这些合法的调用者身份存入数据库,并给每一个调用者都设置密钥。接下来的步骤就简单了:
当访问某个微服务时,需要携带自己的身份信息,比如密钥
被调用者验证身份信息身份合法
如果验证通过则放行,允许访问
因此,我们必须在一个微服务来管理调用者身份、权限、当然还包括用户的权限,角色等,并对外提供验证调用者身份、查询调用者权限的接口,我们可以再ly-auth中完成这些业务。
流程图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9vdPam35-1597970259375)(assets/1558232369089.png)]
加入服务鉴权流程后有没有什么问题呢?
服务调用本来是访问者(client)与微服务(server)之间的交互,但是为了验证身份,不得不与授权中心交互。每次请求都会比原来多一次网络交互,效率大大降低。
能不能只验证一次呢?
如果我们将第一次验证后的身份信息生成一个令牌(token),以后每次请求携带这个token,只要验证token有效,就无需每次调用授权中心验证身份了!
服务调用方需要向授权中心申请令牌,而后每次请求微服务都携带这个令牌即可,而令牌的生成我们依然使用JWT规范来实现。如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KOlKPQDw-1597970259377)(assets/1558231916178.png)]
整个过程是不是跟用户登录也请求服务有点像啊?
没错,其实服务授权,就是把微服务也当做用户来看待。区别在于服务授权无需注册,而是有管理人员提前录入服务及服务的权限信息。
不过这里依然有问题需要思考:
如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AWTCNofC-1597970259381)(assets/1558232611674.png)]
关键的步骤如下:
接下来,我们就实现上面的整个流程。
首先,我们需要在数据库中录入服务调用者的身份信息,权限信息。
来看下表结构:
微服务信息表:tb_application
CREATE TABLE `tb_application` (
`id` int(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`service_name` varchar(32) NOT NULL COMMENT '服务名称',
`secret` varchar(60) NOT NULL COMMENT '密钥',
`info` varchar(128) DEFAULT NULL COMMENT '服务介绍',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_key_service_name` (`service_name`) USING HASH
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8 COMMENT='服务信息表,记录微服务的id,名称,密文,用来做服务认证';
将来需要根据serviceName和secret来做身份认证,获取token。
服务权限表:tb_application_privilege
CREATE TABLE `tb_service_privilege` (
`service_id` int(20) NOT NULL COMMENT '服务id',
`target_id` int(20) NOT NULL COMMENT '目标服务id',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`service_id`,`target_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='服务中间表,记录服务id以及服务能访问的目标服务的id';
服务权限记录包含2个信息:
可以看做是一个中间表,记录服务与被调用服务的关系。这张表中没有的,就不能调用。
因此可以认为是tb_application
的表自关联,多对多关系。
另外,数据库中数据缺少了ly-auth服务的信息,所以这里有一条sql,可以补充添加相关数据:
INSERT INTO `tb_application` (
`id`,
`service_name`,
`secret`,
`info`
)
VALUES
(
null,
'auth-service',
'$2a$10$6nqXhZPyosx71JnWaR2bXu/KbbmTbW6JhnazPKZk77JItm6LAG6YW',
'授权微服务'
);
INSERT INTO `tb_application_privilege` (`service_id`, `target_id`)
VALUES
(1, 10),
(2, 10),
(3, 10),
(4, 10),
(5, 10),
(6, 10),
(7, 10),
(8, 10),
(9, 10),
(10, 1);
有了上面的数据库表,就具备了服务信息和权限信息。接下来就需要有验证服务id和secret、查询服务权限的功能:
以上业务都在**ly-auth
**服务中实现。
在ly-auth
中创建于数据库对应的持久对象:
package com.leyou.auth.entity;
import lombok.Data;
import tk.mybatis.mapper.annotation.KeySql;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Date;
@Data
@Table(name = "tb_application")
public class ApplicationInfo {
@Id
@KeySql(useGeneratedKeys = true)
private Long id;
/**
* 服务名称
*/
private String serviceName;
/**
* 服务密钥
*/
private String secret;
/**
* 服务信息
*/
private String info;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}
要使用数据库相关业务,必须引入通用mapper等依赖:
<dependency>
<groupId>tk.mybatisgroupId>
<artifactId>mapper-spring-boot-starterartifactId>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
然后在application.yml配置文件中添加与数据库相关配置:
spring:
application:
name: auth-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/heima?allowMultiQueries=true
username: root
password: root
mybatis:
type-aliases-package: com.leyou.auth.entity
mapper-locations: mappers/*.xml
configuration:
map-underscore-to-camel-case: true
logging:
level:
com.leyou: trace
mapper:
wrap-keyword: "`{0}`"
然后在启动类上引入mapper扫描包,并且要取出忽略DataSource:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VP8ZWiqp-1597970259384)(assets/1558237863370.png)]
编写mapper继承通用mapper
package com.leyou.auth.mapper;
import com.leyou.auth.entity.ApplicationInfo;
import com.leyou.common.mapper.BaseMapper;
public interface ApplicationInfoMapper extends BaseMapper<ApplicationInfo> {
}
另外,将来一部分业务需要手写sql,可以先定义一个mapper文件:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZDGfmh7s-1597970259386)(assets/1558238035657.png)]
编写基本信息:
<mapper namespace="com.leyou.auth.mapper.ApplicationInfoMapper">
mapper>
查询服务权限,就是根据application的id查询出可以访问的target的id的集合。
先定义一个接口:
package com.leyou.auth.mapper;
import com.leyou.auth.entity.ApplicationInfo;
import com.leyou.common.mappers.BaseMapper;
import java.util.List;
public interface ApplicationInfoMapper extends BaseMapper<ApplicationInfo> {
List<Long> queryTargetIdList(Long serviceId);
}
对应的Sql语句:
<mapper namespace="com.leyou.auth.mapper.ApplicationInfoMapper">
<select id="queryTargetIdList" resultType="java.lang.Long">
SELECT target_id FROM tb_application_privilege WHERE service_id = #{id}
select>
mapper>
有了服务信息,接下来就需要在授权中心ly-auth
编写服务验证并签发JWT的接口了。这个接口与登录非常相似,流程是下图中的红圈中的部分:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BLx3Gjc1-1597970259393)(assets/1558239341851.png)]
基本思路如下:
因此,这里我们需要一个新的载荷对象:AppInfo
package com.leyou.common.auth.entity;
import lombok.Data;
import java.util.List;
@Data
public class AppInfo {
private Long id;
private String serviceName;
private List<Long> targetList;
}
请求相关信息:
/**
* 微服务认证并申请令牌
*
* @param id 服务id
* @param secret 密码
* @return 无
*/
@GetMapping("authorization")
public ResponseEntity<String> authorize(@RequestParam("id") Long id, @RequestParam("secret") String secret) {
return ResponseEntity.ok(authService.authenticate(id, secret));
}
这里token并没有写入cookie,而是作为返回值。
生成token的过程中,需要设置有效时间,因此我们写入到配置文件,这个配置与用户登录的User无关,我们写到另一个配置中:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wZ4Xrxj2-1597970259404)(assets\1560174799179.png)]
然后在JwtProperties中读取:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xB4Kqs6s-1597970259406)(assets/1558090278448.png)]
完整代码:
package com.leyou.auth.config;
import com.leyou.common.auth.utils.RsaUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.security.PrivateKey;
import java.security.PublicKey;
@Slf4j
@Data
@ConfigurationProperties("ly.jwt")
public class JwtProperties implements InitializingBean {
/**
* 公钥地址
*/
private String pubKeyPath;
/**
* 私钥地址
*/
private String priKeyPath;
/**
* 用户相关的属性
*/
private UserTokenProperties user = new UserTokenProperties();
/**
* 公钥对象
*/
private PublicKey publicKey;
/**
* 私钥对象
*/
private PrivateKey privateKey;
private AppTokenProperties app = new AppTokenProperties();
@Data
public class UserTokenProperties {
/**
* token过期时长
*/
private int expire;
/**
* 存放token的cookie名称
*/
private String cookieName;
/**
* 存放token的cookie的domain
*/
private String cookieDomain;
/**
* 刷新时间
*/
private int refreshInterval;
}
@Data
public class AppTokenProperties {
private int expire;
}
@Override
public void afterPropertiesSet() {
try {
publicKey = RsaUtils.getPublicKey(pubKeyPath);
privateKey = RsaUtils.getPrivateKey(priKeyPath);
} catch (Exception e) {
log.error("加载公钥和私钥异常。系统崩溃!");
throw new RuntimeException(e);
}
}
}
数据库中的secret也是加密存储的,因此需要通过之前学习过的PasswordEncoder来加密和验证。
在yaml文件中引入密码相关配置:
ly:
encoder:
crypt:
secret: ${random.uuid}
strength: 10
编写配置类,配置PasswordEncoder:
package com.leyou.auth.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.security.SecureRandom;
@Data
@Configuration
@ConfigurationProperties(prefix = "ly.encoder.crypt")
public class PasswordConfig {
private int strength;
private String secret;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
// 利用密钥生成随机安全码
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
// 初始化BCryptPasswordEncoder
return new BCryptPasswordEncoder(strength, secureRandom);
}
}
结构:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5MrrKzSC-1597970259411)(assets/1558244249362.png)]
在AuthService中,添加新的方法:
package com.leyou.auth.service;
import com.leyou.auth.config.JwtProperties;
import com.leyou.auth.entity.ApplicationInfo;
import com.leyou.auth.mapper.ApplicationInfoMapper;
import com.leyou.common.auth.entity.AppInfo;
import com.leyou.common.auth.entity.UserInfo;
import com.leyou.common.auth.utils.JwtUtils;
import com.leyou.common.enums.ExceptionEnum;
import com.leyou.common.execeptions.LyException;
import com.leyou.common.utils.CookieUtils;
import com.leyou.user.client.UserClient;
import com.leyou.user.dto.UserDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
@Slf4j
@Service
public class AuthService {
@Autowired
private JwtProperties prop;
@Autowired
private ApplicationInfoMapper appMapper;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
// 其它代码略...
public String authenticate(Long id, String secret) {
// 根据id查询应用信息
ApplicationInfo app = appMapper.selectByPrimaryKey(id);
// 判断是否为空
if (app == null) {
// id不存在,抛出异常
throw new LyException(ExceptionEnum.INVALID_SERVER_ID_SECRET);
}
// 校验密码
if (!passwordEncoder.matches(secret, app.getSecret())) {
// 密码错误
throw new LyException(ExceptionEnum.INVALID_SERVER_ID_SECRET);
}
// 查询app的权限信息
List<Long> idList = appMapper.queryTargetIdList(id);
// 封装AppInfo
AppInfo appInfo = new AppInfo();
appInfo.setId(id);
appInfo.setServiceName(app.getServiceName());
appInfo.setTargetList(idList);
// 生成JWT并返回
return JwtUtils.generateTokenExpireInMinutes(
appInfo, prop.getPrivateKey(), prop.getApp().getExpire());
}
}
凡是需要调用其它微服务的服务,都需要申请JWT,否则请求将来会被拦截。包括下列服务:
上述这些微服务可以分成两类来处理:
因此下面我们以ly-gateway和ly-auth为例来讲解如何在项目启动时加载JWT。
申请token,必须携带服务id和服务密钥信息,这两部分我们设置到配置文件中:
ly:
jwt:
pubKeyPath: D:/heima/rsa/id_rsa.pub # 公钥地址
user:
cookieName: LY_TOKEN # cookie名称
app:
id: 7 # 服务id
secret: ${spring.application.name} # 服务密钥,默认是服务的名称
然后在配置类JwtProperties中读取:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KSwea455-1597970259433)(assets/1558090438882.png)]
package com.leyou.gateway.config;
import com.leyou.common.auth.utils.RsaUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.security.PublicKey;
@Data
@Slf4j
@ConfigurationProperties(prefix = "ly.jwt")
public class JwtProperties implements InitializingBean {
/**
* 公钥地址
*/
private String pubKeyPath;
/**
* 用户token相关属性
*/
private UserTokenProperties user = new UserTokenProperties();
private PublicKey publicKey;
@Data
public class UserTokenProperties {
/**
* 存放token的cookie名称
*/
private String cookieName;
}
/**
* 服务认证token相关属性
*/
private PrivilegeTokenProperties app = new PrivilegeTokenProperties();
@Data
public class PrivilegeTokenProperties{
/**
* 服务id
*/
private Long id;
/**
* 服务密钥
*/
private String secret;
}
@Override
public void afterPropertiesSet() throws Exception {
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
log.error("初始化公钥失败!", e);
throw new RuntimeException(e);
}
}
}
接下来,定义一个Feign的客户端,用来调用授权中心的授权认证接口:
先在ly-gateway引入Feign依赖:
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
dependency>
然后在启动类上添加注解,开启Feign:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GgngrTbd-1597970259435)(assets\1560014831026.png)]
代码实现:
package com.leyou.gateway.client;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient("auth-service")
public interface AuthClient {
/**
* 微服务认证并申请令牌
*
* @param id 服务id
* @param secret 密码
* @return 无
*/
@GetMapping("authorization")
String authorize(@RequestParam("id") Long id, @RequestParam("secret") String secret);
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KwrfCVGD-1597970259440)(assets/1554887850102.png)]
按照之前的分析,获取token可通过定时任务来完成,我们之前生成token的有效期已经设置为了25小时。那么更新token的频率可以设置为24小时,避免时间误差。
那么,如何编写定时任务呢?
Spring 中已经集成了对定时任务的支持,使用非常简单。
1)开启定时任务
在启动类上添加注解,即可开启定时任务:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-323llcLe-1597970259441)(assets\1560014920290.png)]
2)配置定时任务
定义普通的Bean,并通过注解配置定时任务:
package com.leyou.gateway.task;
import com.leyou.gateway.client.AuthClient;
import com.leyou.gateway.config.JwtProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 定时获取token,保存token
*/
@Slf4j
@Component
public class PrivilegeTokenHolder {
@Autowired
private JwtProperties prop;
private String token;
/**
* token刷新间隔
*/
private static final long TOKEN_REFRESH_INTERVAL = 86400000L;
/**
* token获取失败后重试的间隔
*/
private static final long TOKEN_RETRY_INTERVAL = 10000L;
@Autowired
private AuthClient authClient;
@Scheduled(fixedDelay = TOKEN_REFRESH_INTERVAL)
public void loadToken() throws InterruptedException {
while (true) {
try {
// 向ly-auth发起请求,获取JWT
this.token = authClient.authorize(prop.getApp().getId(), prop.getApp().getSecret());
log.info("【网关】定时获取token成功");
break;
} catch (Exception e) {
log.info("【网关】定时获取token失败");
}
// 休眠10秒,再次重试
Thread.sleep(TOKEN_RETRY_INTERVAL);
}
}
public String getToken(){
return token;
}
}
定时任务的关键是这行代码:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hOJMOGax-1597970259445)(assets/1558245378082.png)]
解读:
此处我们选择了fixedDelay,并定义了固定时长:86400000毫秒,也就是24小时。
授权中心本身就是签发token的,但是它也需要调用ly-user,因此我们在ly-auth中定时获取token,可以省略验证id和secret的过程,因为本身自己给自己签发token,还需要去找别人验证,这不等于自己证明自己是谁吗,多余。
一样,先配置基本属性:
ly:
jwt:
pubKeyPath: D:/heima/rsa/id_rsa.pub # D:/heima/rsa/id_rsa.pub # 公钥地址
priKeyPath: D:/heima/rsa/id_rsa # D:/heima/rsa/id_rsa # 私钥地址
user:
expire: 30 # 过期时间,单位分钟
cookieName: LY_TOKEN # cookie名称
cookieDomain: leyou.com # cookie的域
refreshInterval: 10 # 刷新token的周期
app:
expire: 1500 # 过期时间,单位分钟
id: 10 # auth服务的id
secret: ${spring.application.name} # auth服务的密钥,默认也是服务名称
在配置类中读取:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oqtQjRoD-1597970259450)(assets/1558090752072.png)]
因为是给自己签发token,无需远程调用,所以直接自己生成即可:
启动类上添加注解:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xfWQMaSB-1597970259453)(assets/1554888457283.png)]
定时任务:
package com.leyou.auth.task;
import com.leyou.auth.config.JwtProperties;
import com.leyou.auth.mapper.ApplicationInfoMapper;
import com.leyou.common.auth.entity.AppInfo;
import com.leyou.common.auth.utils.JwtUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
public class AppTokenHolder {
/**
* token刷新间隔
*/
private static final long TOKEN_REFRESH_INTERVAL = 86400000L;
/**
* token获取失败后重试的间隔
*/
private static final long TOKEN_RETRY_INTERVAL = 10000L;
private String token;
@Autowired
private ApplicationInfoMapper infoMapper;
@Autowired
private JwtProperties prop;
@Scheduled(fixedDelay = TOKEN_REFRESH_INTERVAL)
public void loadTokenTask() throws InterruptedException {
while (true){
try {
// 查询服务信息
List<Long> idList = infoMapper.queryTargetIdList(prop.getApp().getId());
// 封装载荷
AppInfo appInfo = new AppInfo();
appInfo.setId(prop.getApp().getId());
appInfo.setServiceName(prop.getApp().getSecret());
appInfo.setTargetList(idList);
// 发起请求,申请token
this.token = JwtUtils.generateTokenExpireInMinutes(
appInfo, prop.getPrivateKey(), prop.getApp().getExpire());
log.info("【auth服务】申请token成功!");
break;
}catch (Exception e){
log.info("【auth服务】申请token失败!", e);
}
Thread.sleep(TOKEN_RETRY_INTERVAL);
}
}
public String getToken(){
return token;
}
}
有了token后的下一步,就是在每次请求时都携带上token。这里有两个问题需要思考:
问题1:请求中携带token,放到请求中的哪里呢?
请求中可以携带数据的地方有下面几种选择:
我们不能把token放到请求参数中,因为请求调用的接口,其参数都是固定的,我们不能随意修改。
综上所述,我们使用请求头来携带token。
问题2:如何对服务间调用的请求拦截和修改呢?
对于不同微服务有两种的不同的实现方式:
这里微服务之间调用我们依然是ly-auth为例来演示。
首先配置token在请求头中的名称,修改ly-gateway
的application.yml
:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9drRXdgD-1597970259455)(assets\1560164438115.png)]
在ly-gateway
配置类JwtProperties
中添加属性:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7njgrDVQ-1597970259459)(assets/1554890787133.png)]
要在转发前修改请求头,肯定是通过网关的拦截器来做:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SKUC5D2X-1597970259460)(assets/1554889118137.png)]
代码:
package com.leyou.gateway.filters;
import com.leyou.gateway.config.JwtProperties;
import com.leyou.gateway.task.PrivilegeTokenHolder;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
@Component
public class PrivilegeFilter extends ZuulFilter {
@Autowired
private JwtProperties prop;
@Autowired
private PrivilegeTokenHolder tokenHolder;
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
/**
* PRE_DECORATION_FILTER 是Zuul默认的处理请求头的过滤器,我们放到这个之后执行
* @return 顺序
*/
@Override
public int filterOrder() {
return FilterConstants.PRE_DECORATION_FILTER_ORDER + 1;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
// 获取上下文
RequestContext ctx = RequestContext.getCurrentContext();
// 将token存入请求头中
ctx.addZuulRequestHeader(prop.getApp().getHeaderName(), tokenHolder.getToken());
return null;
}
}
这里以ly-auth为例来演示。
首先,也需要配置请求头的名称,修改ly-auth
中的application.yml
:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Uz9HRaHh-1597970259462)(assets\1560175675616.png)]
然后在配置类JwtProperties中读取:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Dtr7in3t-1597970259463)(assets\1560164867649.png)]
微服务间调用是通过Feign来实现的,而Feign是声明式调用,看不到请求的过程。
因此要想对请求修改,必须通过Feign的拦截器来实现。
Feign中有一个拦截器接口:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gZvAchzD-1597970259466)(assets/1554890173148.png)]
可以看到接口中有一个抽象方法:apply(RequestTemplate template)
,在请求发出之前会调用拦截器的apply方法。其参数:template可以对请求进行任意个性化修改。
因此我们需要实现这样一个接口,并且在其中添加请求头,而要让这个接口生效,还需要把这个拦截器注册到Spring容器中。
我们以ly-auth为例来演示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yKwjxs7O-1597970259467)(assets/1558403480390.png)]
代码:
package com.leyou.auth.feign;
import com.leyou.auth.config.JwtProperties;
import com.leyou.auth.task.AppTokenHolder;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class PrivilegeInterceptor implements RequestInterceptor {
@Autowired
private JwtProperties prop;
@Autowired
private AppTokenHolder tokenHolder;
@Override
public void apply(RequestTemplate template) {
// 获取token
String token = tokenHolder.getToken();
// 给请求添加头信息
template.header(prop.getApp().getHeaderName(), token);
}
}
最后,我们需要在被调用的微服务中验证每一个请求,检查请求头中的token是否有效。
微服务的接口都是以SpringMVC来实现的,因此验证的代码可以放到SpringMVC的拦截器来实现。
我们以ly-user
为例来演示。
首先修改application.yml,添加与权限验证相关配置:
ly:
encoder:
crypt:
secret: ${random.uuid} # 随机的密钥,使用uuid
strength: 10 # 加密强度4~31,决定了密码和盐加密时的运算次数,超过10以后加密耗时会显著增加
jwt:
pubKeyPath: D:/heima/rsa/id_rsa.pub # 公钥地址
app:
id: 1 # 服务id
secret: ${spring.application.name} # 服务密钥,默认是服务的名称
headerName: privilege_token
然后在JwtProperties中读取:
package com.leyou.user.config;
import com.leyou.common.auth.utils.RsaUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.security.PublicKey;
@Data
@Slf4j
@ConfigurationProperties(prefix = "ly.jwt")
public class JwtProperties implements InitializingBean {
/**
* 公钥地址
*/
private String pubKeyPath;
/**
* 服务认证token相关属性
*/
private PrivilegeTokenProperties app = new PrivilegeTokenProperties();
private PublicKey publicKey;
@Data
public class PrivilegeTokenProperties{
/**
* 服务id
*/
private Long id;
/**
* 服务密钥
*/
private String secret;
/**
* 存放服务认证token的请求头
*/
private String headerName;
}
@Override
public void afterPropertiesSet() throws Exception {
try {
// 获取公钥和私钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
} catch (Exception e) {
log.error("初始化公钥失败!", e);
throw new RuntimeException(e);
}
}
}
需要定义的包括一个用来拦截请求的拦截器,以及注册拦截器的SpringMVC的配置:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lwjkHUFa-1597970259500)(assets/1558247868145.png)]
代码实现:
package com.leyou.user.interceptors;
import com.leyou.common.auth.entity.AppInfo;
import com.leyou.common.auth.entity.Payload;
import com.leyou.common.auth.utils.JwtUtils;
import com.leyou.user.config.JwtProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 校验请求头中的token
*/
@Slf4j
public class PrivilegeInterceptor implements HandlerInterceptor {
private JwtProperties jwtProp;
public PrivilegeInterceptor(JwtProperties jwtProp) {
this.jwtProp = jwtProp;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try {
// 获取请求头
String token = request.getHeader(jwtProp.getApp().getHeaderName());
// 校验
Payload<AppInfo> payload = JwtUtils.getInfoFromToken(token, jwtProp.getPublicKey(), AppInfo.class);
// 获取token中的服务信息,
AppInfo appInfo = payload.getUserInfo();
// 验证是否有访问本服务的许可
List<Long> targetList = appInfo.getTargetList();
Long currentServiceId = jwtProp.getApp().getId();
if(targetList == null || !targetList.contains(currentServiceId)){
// 没有访问权限,抛出异常
log.error("请求者没有访问本服务的权限!");
}
log.info("服务{}正在请求资源:{}", appInfo.getServiceName(), request.getRequestURI());
return true;
}catch (Exception e){
log.error("服务访问被拒绝,token认证失败!", e);
return false;
}
}
}
配置MvcConfig,让拦截器生效:
package com.leyou.user.config;
import com.leyou.user.interceptors.PrivilegeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private JwtProperties prop;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new PrivilegeInterceptor(prop)).excludePathPatterns("/swagger-ui.html");
}
}
你们使用JWT做登录凭证,如何解决token注销问题
答:jwt的缺陷是token生成后无法修改,因此无法让token失效。只能采用其它方案来弥补,基本思路如下:
1)适当减短token有效期,让token尽快失效
2)删除客户端cookie
3)服务端对失效token进行标记,形成黑名单,虽然有违无状态特性,但是因为token有效期短,因此标记 时间也比较短。服务器压力会比较小
既然token有效期短,怎么解决token失效后的续签问题?
答:在验证用户登录状态的代码中,添加一段逻辑:判断cookie即将到期时,重新生成一个token。比如token有效期为30分钟,当用户请求我们时,我们可以判断如果用户的token有效期还剩下10分钟,那么就重新生成token。因此用户只要在操作我们的网站,就会续签token
如何解决异地登录问题?
答:在我们的应用中是允许用户异地登录的。如果要禁止用户异地登录,只能采用有状态方式,在服务端记录登录用户的信息,并且判断用户已经登录,并且在其它设备再次登录时,禁止登录请求,并要求发送短信验证。
如何解决cookie被盗用问题?
答:cookie被盗用的可能性主要包括下面几种:
如何解决cookie被篡改问题?
如何完成权限校验的?
服务端微服务地址不小心暴露了,用户就可以绕过网关,直接访问微服务,怎么办?
t org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private JwtProperties prop;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new PrivilegeInterceptor(prop)).excludePathPatterns("/swagger-ui.html");
}
}
# 3.面试常见问题
- 你们使用JWT做登录凭证,如何解决token注销问题
答:jwt的缺陷是token生成后无法修改,因此无法让token失效。只能采用其它方案来弥补,基本思路如下:
1)适当减短token有效期,让token尽快失效
2)删除客户端cookie
3)服务端对失效token进行标记,形成黑名单,虽然有违无状态特性,但是因为token有效期短,因此标记 时间也比较短。服务器压力会比较小
- 既然token有效期短,怎么解决token失效后的续签问题?
答:在验证用户登录状态的代码中,添加一段逻辑:判断cookie即将到期时,重新生成一个token。比如token有效期为30分钟,当用户请求我们时,我们可以判断如果用户的token有效期还剩下10分钟,那么就重新生成token。因此用户只要在操作我们的网站,就会续签token
- 如何解决异地登录问题?
答:在我们的应用中是允许用户异地登录的。如果要禁止用户异地登录,只能采用有状态方式,在服务端记录登录用户的信息,并且判断用户已经登录,并且在其它设备再次登录时,禁止登录请求,并要求发送短信验证。
- 如何解决cookie被盗用问题?
答:cookie被盗用的可能性主要包括下面几种:
- XSS攻击:这个可以在前端页面渲染时对 数据做安全处理即可,而且我们的cookie使用了Httponly为true,可以防止JS脚本的攻击。
- CSRF攻击:
- 我们严格遵循了Rest风格,CSRF只能发起Get请求,不会对服务端造成损失,可以有效防止CSRF攻击
- 利用Referer头,防盗链
- 抓包,获取用户cookie:我们采用了HTTPS协议通信,无法获取请求的任何数据
- 请求重放攻击:对于普通用户的请求没有对请求重放做防御,而是对部分业务做好了幂等处理。运行管理系统中会对token添加随机码,认证token一次有效,来预防请求重放攻击。
- 用户电脑中毒:这个无法防范。
- 如何解决cookie被篡改问题?
- 答:cookie可以篡改,但是签名无法篡改,否则服务端认证根本不会通过
- 如何完成权限校验的?
- 首先我们有权限管理的服务,管理用户的各种权限,及可访问路径等
- 在网关zuul中利用Pre过滤器,拦截一切请求,在过滤器中,解析jwt,获取用户身份,查询用户权限,判断用户身份可以访问当前路径
- 服务端微服务地址不小心暴露了,用户就可以绕过网关,直接访问微服务,怎么办?
- 答:我们的微服务都做了严格的服务间鉴权处理,任何对微服务的访问都会被验证是否有授权,如果没有则会被拦截。具体实现:此处省略500字,见本节课内容