服务网关 Zuul 与 Redis 结合实现 Token 权限校验
这两天在写项目的全局权限校验,用 Zuul 作为服务网关,在 Zuul 的前置过滤器里做的校验。
权限校验或者身份验证就不得不提 Token,目前 Token 的验证方式有很多种,有生成 Token 后将 Token 存储在 Redis 或数据库的,也有很多用 JWT(JSON Web Token)的。
说实话这方面我的经验不多,又着急赶项目,所以就先用个简单的方案。
登录成功后将 Token 返回给前端,同时将 Token 存在 Redis 里。每次请求接口都从 Cookie 或 Header 中取出 Token,在从 Redis 中取出存储的 Token,比对是否一致。
我知道这方案不是最完美的,还有安全性问题,容易被劫持。但目前的策略是先把项目功能做完,上线之后再慢慢优化,不在一个功能点上扣的太细,保证项目进度不至于太慢。
项目地址:https://github.com/cachecats/coderiver
本文将分四部分介绍
- 登录逻辑
- AuthFilter 前置过滤器校验逻辑
- 工具类
- 演示验证
一、登录逻辑
登录成功后,将生成的 Token 存储在 Redis 中。用 String 类型的 key, value 格式存储,key是 TOKEN_userId
,如果用户的 userId 是 222222
,那键就是 TOKEN_222222
;值是生成的 Token。
只贴出登录的 Serive 代码
@Override
public UserInfoDTO loginByEmail(String email, String password) {
<span class="hljs-keyword">if</span> (StringUtils.isEmpty(email) || StringUtils.isEmpty(password)) {
<span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> UserException(ResultEnum.EMAIL_PASSWORD_EMPTY);
}
UserInfo user = userRepository.findUserInfoByEmail(email);
<span class="hljs-keyword">if</span> (user == <span class="hljs-keyword">null</span>) {
<span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> UserException(ResultEnum.EMAIL_NOT_EXIST);
}
<span class="hljs-keyword">if</span> (!user.getPassword().equals(password)) {
<span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> UserException(ResultEnum.PASSWORD_ERROR);
}
<span class="hljs-comment">//生成 token 并保存在 Redis 中</span>
String token = KeyUtils.genUniqueKey();
<span class="hljs-comment">//将token存储在 Redis 中。键是 TOKEN_用户id, 值是token</span>
redisUtils.setString(String.format(RedisConsts.TOKEN_TEMPLATE, user.getId()), token, <span class="hljs-number">2l</span>, TimeUnit.HOURS);
UserInfoDTO dto = <span class="hljs-keyword">new</span> UserInfoDTO();
BeanUtils.copyProperties(user, dto);
dto.setToken(token);
<span class="hljs-keyword">return</span> dto;
}
二、AuthFilter 前置过滤器
AuthFilter
继承自 ZuulFilter
,必须实现 ZuulFilter
的四个方法。
filterType()
: Filter 的类型,前置过滤器返回PRE_TYPE
filterOrder()
: Filter 的顺序,值越小越先执行。这里的写法是PRE_DECORATION_FILTER_ORDER - 1
, 也是官方建议的写法。shouldFilter()
: 是否应该过滤。返回 true 表示过滤,false 不过滤。可以在这个方法里判断哪些接口不需要过滤,本例排除了注册和登录接口,除了这两个接口,其他的都需要过滤。run()
: 过滤器的具体逻辑
为了方便前端,考虑到要给 pc、app、小程序等不同平台提供服务,token 设置在 cookie 和 header 任选一均可,会先从 cookie 中取,cookie 中没有再从 header 中取。
package com.solo.coderiver.gateway.filter;
import com.google.gson.Gson;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.solo.coderiver.gateway.VO.ResultVO;
import com.solo.coderiver.gateway.consts.RedisConsts;
import com.solo.coderiver.gateway.utils.CookieUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_DECORATION_FILTER_ORDER;
import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;
/**
权限验证 Filter
注册和登录接口不过滤
验证权限需要前端在 Cookie 或 Header 中(二选一即可)设置用户的 userId 和 token
因为 token 是存在 Redis 中的,Redis 的键由 userId 构成,值是 token
在两个地方都没有找打 userId 或 token其中之一,就会返回 401 无权限,并给与文字提示
*/
@Slf4j
@Component
public class AuthFilter extends ZuulFilter {
<span class="hljs-meta">@Autowired</span>
StringRedisTemplate stringRedisTemplate;
<span class="hljs-comment">//排除过滤的 uri 地址</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String LOGIN_URI = <span class="hljs-string">"/user/user/login"</span>;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String REGISTER_URI = <span class="hljs-string">"/user/user/register"</span>;
<span class="hljs-comment">//无权限时的提示语</span>
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String INVALID_TOKEN = <span class="hljs-string">"invalid token"</span>;
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> String INVALID_USERID = <span class="hljs-string">"invalid userId"</span>;
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> String <span class="hljs-title">filterType</span><span class="hljs-params">()</span> </span>{
<span class="hljs-keyword">return</span> PRE_TYPE;
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> <span class="hljs-title">filterOrder</span><span class="hljs-params">()</span> </span>{
<span class="hljs-keyword">return</span> PRE_DECORATION_FILTER_ORDER - <span class="hljs-number">1</span>;
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">shouldFilter</span><span class="hljs-params">()</span> </span>{
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
log.info(<span class="hljs-string">"uri:{}"</span>, request.getRequestURI());
<span class="hljs-comment">//注册和登录接口不拦截,其他接口都要拦截校验 token</span>
<span class="hljs-keyword">if</span> (LOGIN_URI.equals(request.getRequestURI()) ||
REGISTER_URI.equals(request.getRequestURI())) {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
}
<span class="hljs-meta">@Override</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> Object <span class="hljs-title">run</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> ZuulException </span>{
RequestContext requestContext = RequestContext.getCurrentContext();
HttpServletRequest request = requestContext.getRequest();
<span class="hljs-comment">//先从 cookie 中取 token,cookie 中取失败再从 header 中取,两重校验</span>
<span class="hljs-comment">//通过工具类从 Cookie 中取出 token</span>
Cookie tokenCookie = CookieUtils.getCookieByName(request, <span class="hljs-string">"token"</span>);
<span class="hljs-keyword">if</span> (tokenCookie == <span class="hljs-keyword">null</span> || StringUtils.isEmpty(tokenCookie.getValue())) {
readTokenFromHeader(requestContext, request);
} <span class="hljs-keyword">else</span> {
verifyToken(requestContext, request, tokenCookie.getValue());
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
}
<span class="hljs-comment">/**
- 从 header 中读取 token 并校验
/</span>
<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title">readTokenFromHeader</span><span class="hljs-params">(RequestContext requestContext, HttpServletRequest request)</span> </span>{
<span class="hljs-comment">//从 header 中读取</span>
String headerToken = request.getHeader(<span class="hljs-string">"token"</span>);
<span class="hljs-keyword">if</span> (StringUtils.isEmpty(headerToken)) {
setUnauthorizedResponse(requestContext, INVALID_TOKEN);
} <span class="hljs-keyword">else</span> {
verifyToken(requestContext, request, headerToken);
}
}
<span class="hljs-comment">/* - 从Redis中校验token
/</span>
<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title">verifyToken</span><span class="hljs-params">(RequestContext requestContext, HttpServletRequest request, String token)</span> </span>{
<span class="hljs-comment">//需要从cookie或header 中取出 userId 来校验 token 的有效性,因为每个用户对应一个token,在Redis中是以 TOKEN_userId 为键的</span>
Cookie userIdCookie = CookieUtils.getCookieByName(request, <span class="hljs-string">"userId"</span>);
<span class="hljs-keyword">if</span> (userIdCookie == <span class="hljs-keyword">null</span> || StringUtils.isEmpty(userIdCookie.getValue())) {
<span class="hljs-comment">//从header中取userId</span>
String userId = request.getHeader(<span class="hljs-string">"userId"</span>);
<span class="hljs-keyword">if</span> (StringUtils.isEmpty(userId)) {
setUnauthorizedResponse(requestContext, INVALID_USERID);
} <span class="hljs-keyword">else</span> {
String redisToken = stringRedisTemplate.opsForValue().get(String.format(RedisConsts.TOKEN_TEMPLATE, userId));
<span class="hljs-keyword">if</span> (StringUtils.isEmpty(redisToken) || !redisToken.equals(token)) {
setUnauthorizedResponse(requestContext, INVALID_TOKEN);
}
}
} <span class="hljs-keyword">else</span> {
String redisToken = stringRedisTemplate.opsForValue().get(String.format(RedisConsts.TOKEN_TEMPLATE, userIdCookie.getValue()));
<span class="hljs-keyword">if</span> (StringUtils.isEmpty(redisToken) || !redisToken.equals(token)) {
setUnauthorizedResponse(requestContext, INVALID_TOKEN);
}
}
}
<span class="hljs-comment">/* 设置 401 无权限状态
/</span>
<span class="hljs-function"><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title">setUnauthorizedResponse</span><span class="hljs-params">(RequestContext requestContext, String msg)</span> </span>{
requestContext.setSendZuulResponse(<span class="hljs-keyword">false</span>);
requestContext.setResponseStatusCode(HttpStatus.UNAUTHORIZED.value());
ResultVO vo = <span class="hljs-keyword">new</span> ResultVO();
vo.setCode(<span class="hljs-number">401</span>);
vo.setMsg(msg);
Gson gson = <span class="hljs-keyword">new</span> Gson();
String result = gson.toJson(vo);
requestContext.setResponseBody(result);
}
}
三、工具类
MD5 工具类
package com.solo.coderiver.user.utils;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/*
生成 MD5 的工具类
/
public class MD5Utils {
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title">getMd5</span><span class="hljs-params">(String plainText)</span> </span>{
<span class="hljs-keyword">try</span> {
MessageDigest md = MessageDigest.getInstance(<span class="hljs-string">"MD5"</span>);
md.update(plainText.getBytes());
<span class="hljs-keyword">byte</span> b[] = md.digest();
<span class="hljs-keyword">int</span> i;
StringBuffer buf = <span class="hljs-keyword">new</span> StringBuffer(<span class="hljs-string">""</span>);
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> offset = <span class="hljs-number">0</span>; offset < b.length; offset++) {
i = b[offset];
<span class="hljs-keyword">if</span> (i < <span class="hljs-number">0</span>)
i += <span class="hljs-number">256</span>;
<span class="hljs-keyword">if</span> (i < <span class="hljs-number">16</span>)
buf.append(<span class="hljs-string">"0"</span>);
buf.append(Integer.toHexString(i));
}
<span class="hljs-comment">//32位加密</span>
<span class="hljs-keyword">return</span> buf.toString();
<span class="hljs-comment">// 16位的加密</span>
<span class="hljs-comment">//return buf.toString().substring(8, 24);</span>
} <span class="hljs-keyword">catch</span> (NoSuchAlgorithmException e) {
e.printStackTrace();
<span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
}
}
<span class="hljs-comment">/*
加密解密算法 执行一次加密,两次解密
/</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title">convertMD5</span><span class="hljs-params">(String inStr)</span></span>{
<span class="hljs-keyword">char</span>[] a = inStr.toCharArray();
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">0</span>; i < a.length; i++){
a[i] = (<span class="hljs-keyword">char</span>) (a[i] ^ <span class="hljs-string">'t'</span>);
}
String s = <span class="hljs-keyword">new</span> String(a);
<span class="hljs-keyword">return</span> s;
}
}
生成 key 的工具类
package com.solo.coderiver.user.utils;
import java.util.Random;
public class KeyUtils {
/*
产生独一无二的key
*/
public static synchronized String genUniqueKey(){
Random random = new Random();
int number = random.nextInt(900000) + 100000;
String key = System.currentTimeMillis() + String.valueOf(number);
return MD5Utils.getMd5(key);
}
}
四、演示验证
在 8084 端口启动 api_gateway
项目,同时启动 user
项目。
用 postman 通过网关访问登录接口,因为过滤器对登录和注册接口排除了,所以不会校验这两个接口的 token。
可以看到,访问地址 http://localhost:8084/user/user/login
登录成功并返回了用户信息和 token。
![](https://ask.qcloudimg.com/http-save/yehe-6412548/763e19c9c813c84127ea7dcd86fa04ea.png?imageView2/2/w/1620)
此时应该把 token 存入 Redis 中了,用户的 id 是 111111
,所以键是 TOKEN_111111
,值是刚生成的 token 值
![](https://ask.qcloudimg.com/http-save/yehe-6412548/73f84279cf22df53bcf53755cd318923.png?imageView2/2/w/1620)
再来随便请求一个其他的接口,应该走过滤器。
header 中不传 token 和 userId,返回 401
![](https://ask.qcloudimg.com/http-save/yehe-6412548/b07ab5cac7b92995983d949525f60494.png?imageView2/2/w/1620)
只传 token 不传 userId,返回401并提示 invalid userId
![](https://ask.qcloudimg.com/http-save/yehe-6412548/0d402dc0fdb4b92e00204604d1c8503f.png?imageView2/2/w/1620)
token 和 userId 都传,但 token 不对,返回401,并提示 invalid token
![](https://ask.qcloudimg.com/http-save/yehe-6412548/9312a53c19bad076265c3193c69d6613.png?imageView2/2/w/1620)
同时传正确的 token 和 userId,请求成功
![](https://ask.qcloudimg.com/http-save/yehe-6412548/a7d82d043114e7bd99a84c2870b775f5.png?imageView2/2/w/1620)
以上就是简单的 Token 校验,如果有更好的方案欢迎在评论区交流
代码出自开源项目 CodeRiver
,致力于打造全平台型全栈精品开源项目。
coderiver 中文名 河码,是一个为程序员和设计师提供项目协作的平台。无论你是前端、后端、移动端开发人员,或是设计师、产品经理,都可以在平台上发布项目,与志同道合的小伙伴一起协作完成项目。
coderiver河码 类似程序员客栈,但主要目的是方便各细分领域人才之间技术交流,共同成长,多人协作完成项目。暂不涉及金钱交易。
计划做成包含 pc端(Vue、React)、移动H5(Vue、React)、ReactNative混合开发、Android原生、微信小程序、java后端的全平台型全栈项目,欢迎关注。
https://cloud.tencent.com/developer/article/1972832