learn.lianglianglee.com/专栏/SpringCloud微服务实战(完)/17 集成网关后怎么做安全验证——统一鉴权.md.html
2022-05-11 18:52:13 +08:00

1071 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/static/favicon.png">
<title>17 集成网关后怎么做安全验证——统一鉴权.md.html</title>
<!-- Spectre.css framework -->
<link rel="stylesheet" href="/static/index.css">
<!-- theme css & js -->
<meta name="generator" content="Hexo 4.2.0">
</head>
<body>
<div class="book-container">
<div class="book-sidebar">
<div class="book-brand">
<a href="/">
<img src="/static/favicon.png">
<span>技术文章摘抄</span>
</a>
</div>
<div class="book-menu uncollapsible">
<ul class="uncollapsible">
<li><a href="/" class="current-tab">首页</a></li>
</ul>
<ul class="uncollapsible">
<li><a href="../">上一级</a></li>
</ul>
<ul class="uncollapsible">
<li>
<a href="/专栏/SpringCloud微服务实战/00 开篇导读.md">00 开篇导读.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/01 以真实“商场停车”业务切入——需求分析.md">01 以真实“商场停车”业务切入——需求分析.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/02 具象业务需求再抽象分解——系统设计.md">02 具象业务需求再抽象分解——系统设计.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/03 第一个 Spring Boot 子服务——会员服务.md">03 第一个 Spring Boot 子服务——会员服务.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/04 如何维护接口文档供外部调用——在线接口文档管理.md">04 如何维护接口文档供外部调用——在线接口文档管理.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/05 认识 Spring Cloud 与 Spring Cloud Alibaba 项目.md">05 认识 Spring Cloud 与 Spring Cloud Alibaba 项目.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/06 服务多不易管理如何破——服务注册与发现.md">06 服务多不易管理如何破——服务注册与发现.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/07 如何调用本业务模块外的服务——服务调用.md">07 如何调用本业务模块外的服务——服务调用.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/08 服务响应慢或服务不可用怎么办——快速失败与服务降级.md">08 服务响应慢或服务不可用怎么办——快速失败与服务降级.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/09 热更新一样更新服务的参数配置——分布式配置中心.md">09 热更新一样更新服务的参数配置——分布式配置中心.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/10 如何高效读取计费规则等热数据——分布式缓存.md">10 如何高效读取计费规则等热数据——分布式缓存.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/11 多实例下的定时任务如何避免重复执行——分布式定时任务.md">11 多实例下的定时任务如何避免重复执行——分布式定时任务.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/12 同一套服务如何应对不同终端的需求——服务适配.md">12 同一套服务如何应对不同终端的需求——服务适配.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/13 采用消息驱动方式处理扣费通知——集成消息中间件.md">13 采用消息驱动方式处理扣费通知——集成消息中间件.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/14 Spring Cloud 与 Dubbo 冲突吗——强强联合.md">14 Spring Cloud 与 Dubbo 冲突吗——强强联合.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/15 破解服务中共性问题的繁琐处理方式——接入 API 网关.md">15 破解服务中共性问题的繁琐处理方式——接入 API 网关.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/16 服务压力大系统响应慢如何破——网关流量控制.md">16 服务压力大系统响应慢如何破——网关流量控制.md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/SpringCloud微服务实战/17 集成网关后怎么做安全验证——统一鉴权.md">17 集成网关后怎么做安全验证——统一鉴权.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/18 多模块下的接口 API 如何统一管理——聚合 API.md">18 多模块下的接口 API 如何统一管理——聚合 API.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/19 数据分库后如何确保数据完整性——分布式事务.md">19 数据分库后如何确保数据完整性——分布式事务.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/20 优惠券如何避免超兑——引入分布式锁.md">20 优惠券如何避免超兑——引入分布式锁.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/21 如何查看各服务的健康状况——系统应用监控.md">21 如何查看各服务的健康状况——系统应用监控.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/22 如何确定一次完整的请求过程——服务链路跟踪.md">22 如何确定一次完整的请求过程——服务链路跟踪.md.html</a>
</li>
<li>
<a href="/专栏/SpringCloud微服务实战/23 结束语.md">23 结束语.md.html</a>
</li>
</ul>
</div>
</div>
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
<div class="sidebar-toggle-inner"></div>
</div>
<script>
function add_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.add('show')
}
function remove_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.remove('show')
}
function sidebar_toggle() {
let sidebar_toggle = document.querySelector('.sidebar-toggle')
let sidebar = document.querySelector('.book-sidebar')
let content = document.querySelector('.off-canvas-content')
if (sidebar_toggle.classList.contains('extend')) { // show
sidebar_toggle.classList.remove('extend')
sidebar.classList.remove('hide')
content.classList.remove('extend')
} else { // hide
sidebar_toggle.classList.add('extend')
sidebar.classList.add('hide')
content.classList.add('extend')
}
}
function open_sidebar() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.add('show')
overlay.classList.add('show')
}
function hide_canvas() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.remove('show')
overlay.classList.remove('show')
}
</script>
<div class="off-canvas-content">
<div class="columns">
<div class="column col-12 col-lg-12">
<div class="book-navbar">
<!-- For Responsive Layout -->
<header class="navbar">
<section class="navbar-section">
<a onclick="open_sidebar()">
<i class="icon icon-menu"></i>
</a>
</section>
</header>
</div>
<div class="book-content" style="max-width: 960px; margin: 0 auto;
overflow-x: auto;
overflow-y: hidden;">
<div class="book-post">
<p id="tip" align="center"></p>
<div><h1>17 集成网关后怎么做安全验证——统一鉴权</h1>
<p>商场停车场景中,除了极少数功能不需要用户登陆外(如可用车位数),其余都是需要用户在会话状态下才能正常使用的功能。上个章节中提到,要在网关层实现统一的认证操作,本篇就直接带你在网关层增加一个公共鉴权功能,来实现简单的认证,采用轻量级解决方案 JWT 的方式来完成。</p>
<h3>为什么选 JWT</h3>
<p>JSON Web Token缩写 JWT是比较流行的轻量级跨域认证解决方案Tomcat 的 Session 方式不太适应分布式环境中多实例多应用的场景。JWT 按一定规则生成并解析,无须存储,仅这一点要完爆 Session 的存储方式,更何况 Session 在多实例环境还需要考虑同步问题,复杂度无形中又增大不少。</p>
<p>由于 JWT 的这种特性,导致 JWT 生成后,只要不过期就可以正常使用,在业务场景中就会存在漏洞,比如会话退出时,但 token 依旧可以使用token 一旦生成,无法更改),此时就需要借助第三方的手段,来配置 token 的验证,防止被别有用意的人利用。</p>
<p>服务只有处于无状态条件下,才能更好的扩展,否则就需要维护状态,增加额外的开销,反而不利于维护扩展,而 JWT 恰恰帮助服务端实例做到了无状态化。</p>
<h4><strong>JWT 应用的两个特殊场景</strong></h4>
<ol>
<li>会话主动退出。必须结合第三方来完成,如 Redis 方案:会话主动退出时,将 token 写入缓存中,后期所有请求在网关层验证时,先判定缓存中是否存在,若存在则证明 token 无效,提示去登陆。</li>
<li>用户一直在使用系统,但 JWT 失效。假如 JWT 有效期是 30 分钟,如果用户一直在使用,表明处于活跃状态,不能直接在 30 分钟后用用户踢出去登陆,用户体验很糟糕。依照 Session 方式下的解雇方案,只要用户在活跃,有效期就要延长。但 JWT 本身又无法更改,这时就需要刷新 JWT 来保证体验的流畅性。方案如下:当检测到即将过期或已经过期时,但用户依旧在活跃(如果判定在活跃?可以将用户的每次请求写入缓存,通过时间间隔判定),则生成新 token 返回给前端,使用新的 token 发起请求,直到主动退出或失效退出。</li>
</ol>
<h3>使用 JWT</h3>
<p>在网关层引入 jar 包:</p>
<pre><code class="language-xml">&lt;!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt --&gt;
&lt;dependency&gt;
&lt;groupId&gt;io.jsonwebtoken&lt;/groupId&gt;
&lt;artifactId&gt;jjwt&lt;/artifactId&gt;
&lt;version&gt;0.9.1&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<h4><strong>编写 JWT 工具类</strong></h4>
<p>工具类功能集中于生成 token 与验证 token</p>
<pre><code class="language-java">@Slf4j
public class JWTUtils {
/**
* 由字符串生成加密 key此处的 key 并没有代码中写死,可以灵活配置
*
* @return
*/
public static SecretKey generalKey(String stringKey) {
byte[] encodedKey = Base64.decodeBase64(stringKey);
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, &quot;AES&quot;);
return key;
}
/**
* createJWT: 创建 jwt&lt;br/&gt;
*
* @author guooo
* @param id 唯一 iduuid 即可
* @param subject json 形式字符串或字符串,增加用户非敏感信息存储,如 user tid与 token 解析后进行对比,防止乱用
* @param ttlMillis 有效期
* @param stringKey
* @return jwt token
* @throws Exception
* @since JDK 1.6
*/
public static String createJWT(String id, String subject, long ttlMillis, String stringKey) throws Exception {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
SecretKey key = generalKey(stringKey);
JwtBuilder builder = Jwts.builder().setIssuer(&quot;&quot;).setId(id).setIssuedAt(now).setSubject(subject)
.signWith(signatureAlgorithm, key);
if (ttlMillis &gt;= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
return builder.compact();
}
/**
* parseJWT: 解密 jwt &lt;br/&gt;
*
* @author guooo
* @param jwt
* @param stringKey
* @return
* @throws ExpiredJwtException
* @throws UnsupportedJwtException
* @throws MalformedJwtException
* @throws SignatureException
* @throws IllegalArgumentException
* @since JDK 1.6
*/
public static Claims parseJWT(String jwt, String stringKey) throws ExpiredJwtException, UnsupportedJwtException,
MalformedJwtException, SignatureException, IllegalArgumentException {
SecretKey key = generalKey(stringKey);
Claims claims = Jwts.parser().setSigningKey(key).parseClaimsJws(jwt).getBody();
return claims;
}
public static boolean isTokenExpire(String jwt, String stringKey) {
Claims aClaims = parseJWT(jwt, stringKey);
// 当前时间与 token 失效时间比较
if (LocalDateTime.now().isAfter(LocalDateTime.now()
.with(aClaims.getExpiration().toInstant().atOffset(ZoneOffset.ofHours(8)).toLocalDateTime()))) {
log.info(&quot;token is valide&quot;);
return true;
} else {
return false;
}
}
public static void main(String[] args) {
try {
String key = &quot;eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0&quot;;
String token = createJWT(UUID.randomUUID().toString().replace(&quot;-&quot;, &quot;&quot;), &quot;1212&quot;, 2000, key);
System.out.println(token);
parseJWT(token, key);
// Thread.sleep(2500);
Claims aClaims = parseJWT(token, key);
System.out.println(aClaims.getExpiration());
if (isTokenExpire(token, key)) {
System.out.println(&quot;过期了&quot;);
} else {
System.out.println(&quot;normal&quot;);
}
System.out.println(aClaims.getSubject().substring(0, 2));
} catch (ExpiredJwtException e) {
System.out.println(&quot;又过期了&quot;);
} catch (Exception e) {
e.printStackTrace();
}
}
}
</code></pre>
<h4><strong>校验 token</strong></h4>
<p>需要要结合 Spring Cloud Gateway 的网关过滤器来验证 token 的可用性,编写过滤器:</p>
<pre><code class="language-java">@Component
@Slf4j
public class JWTFilter implements GlobalFilter, Ordered {
@Autowired
JWTData jwtData;
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono&lt;Void&gt; filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String url = exchange.getRequest().getURI().getPath();
// 跳过不需要验证的路径
if (null != jwtData.getSkipUrls() &amp;&amp; Arrays.asList(jwtData.getSkipUrls()).contains(url)) {
return chain.filter(exchange);
}
// 获取 token
String token = exchange.getRequest().getHeaders().getFirst(&quot;token&quot;);
ServerHttpResponse resp = exchange.getResponse();
if (StringUtils.isEmpty(token)) {
// 没有 token
return authError(resp, &quot;请先登陆!&quot;);
} else {
// 有 token
try {
JWTUtils.parseJWT(token, jwtData.getTokenKey());
log.info(&quot;验证通过&quot;);
return chain.filter(exchange);
} catch (ExpiredJwtException e) {
log.error(e.getMessage(), e);
return authError(resp, &quot;token过期&quot;);
} catch (Exception e) {
log.error(e.getMessage(), e);
return authError(resp, &quot;认证失败&quot;);
}
}
}
/**
* 认证错误输出
*
* @param resp 响应对象
* @param message 错误信息
* @return
*/
private Mono&lt;Void&gt; authError(ServerHttpResponse resp, String message) {
resp.setStatusCode(HttpStatus.UNAUTHORIZED);
resp.getHeaders().add(&quot;Content-Type&quot;, &quot;application/json;charset=UTF-8&quot;);
CommonResult&lt;String&gt; returnData = new CommonResult&lt;&gt;(org.apache.http.HttpStatus.SC_UNAUTHORIZED + &quot;&quot;);
returnData.setRespMsg(message);
String returnStr = &quot;&quot;;
try {
returnStr = objectMapper.writeValueAsString(returnData.getRespMsg());
} catch (JsonProcessingException e) {
log.error(e.getMessage(), e);
}
DataBuffer buffer = resp.bufferFactory().wrap(returnStr.getBytes(StandardCharsets.UTF_8));
return resp.writeWith(Flux.just(buffer));
}
@Override
public int getOrder() {
return -200;
}
}
</code></pre>
<p>上文提到 key 是 JWT 在生成或验证 token 时一个关键参数,就像生成密钥种子一样。此值可以配置在 application.properties 配置文件中,也可以写入 Nacos 中。过滤器中使用到的 JWTData 类,主要用于存储不需要鉴权的请求地址与 JWT 种子 key 的值。</p>
<pre><code class="language-properties">jwt:
token-key: eyJqdGkiOiI1NGEzNmQ5MjhjYzE0MTY2YTk0MmQ5NTg4NGM2Y2JjMSIsImlhdCI6MTU3OTE2MDkwMiwic3ViIjoiMTIxMiIsImV4cCI6MTU3OTE2MDkyMn0
skip-urls:
- /member-service/member/bindMobile
- /member-service/member/logout
@Component
@Data
@ConfigurationProperties(prefix = &quot;jwt&quot;)
public class JWTData {
public String tokenKey;
private String[] skipUrls;
}
</code></pre>
<p>至此,基本的配置与相关功能代码已经完备,下一步进入测试。</p>
<h4><strong>测试可用性</strong></h4>
<p>本次主要来验证特定下,是否会对 token 进行验证,由于 filter 是基于网关的 GlobalFilter会拦截所有的路由请求当是无须验权的请求时则直接转发路由。</p>
<p>先用 JWTUtils 工具,输出一个正常的 token采用 Postman 工具进行“商场用户日常签到功能请求”验证,发现请求成功。</p>
<p><img src="assets/90302b80-a0a7-11ea-a7e9-93a4ac8821bf" alt="img" /></p>
<p>稍等数秒钟,待 token 自动失效后再重新发起请求结果如下图所示请求直接在网关层被拦截返回提示“token 过期”,不再向后端服务转发。</p>
<p><img src="assets/a75fafb0-a0a7-11ea-bf38-950ba54cfedc" alt="img" /></p>
<p>做另外一个测试:伪造一个错误的 token进行请求验证结果如下图所示请求直接在网关层拦截返回同样不再向后端服务转发。</p>
<p><img src="assets/b5d13230-a0a7-11ea-a7e9-93a4ac8821bf" alt="img" /></p>
<p>至此,一个轻量级的网关鉴权方案完成,虽简单但很实用。在应对复杂场景时,还需要配合其它组件或功能来加固服务,保证服务的安全性。比如鉴权通过后,哪些功能有权操作,哪有没有,还需要基于角色权限配置来完成。这在管理系统中很常见,本案例中未体现此块功能,你可以在本案例中尝试增加这块的功能来验证一下,加深对 JWT 的理解。</p>
</div>
</div>
<div>
<div style="float: left">
<a href="/专栏/SpringCloud微服务实战/16 服务压力大系统响应慢如何破——网关流量控制.md">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/SpringCloud微服务实战/18 多模块下的接口 API 如何统一管理——聚合 API.md">下一页</a>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
</div>
<script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"709975b01b623cfa","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-NPSEEVD756');
var path = window.location.pathname
var cookie = getCookie("lastPath");
console.log(path)
if (path.replace("/", "") === "") {
if (cookie.replace("/", "") !== "") {
console.log(cookie)
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
}
} else {
setCookie("lastPath", path)
}
function setCookie(cname, cvalue) {
var d = new Date();
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toGMTString();
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
}
function getCookie(cname) {
var name = cname + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
</script>
</html>