公司最近要上线一个活动功能,由于后端这边的安全等级非常低,用户编号都是通过接口明文传输,稍微懂点技术的都可以利用请求监控拿到请求的URL和参数,由于之前都是赶业务需求,导致后端整体安全性偏低了,所以趁着这次redis也在应用中,所以决定在zuul中加入一个token授权功能,来缓解一下别人非法模拟其他用户编号来请求刷数据。
利用redis + token来实现单点登录
实现思路
Filter
zuul一共有: pre
、route
、post
、error
四种类型的拦截器,分别对应的时机为开始前、执行中、执行后、异常发生。
我们会在post
和pre
两个阶段分别做处理:
- post : 登录完成之后,回调这个接口,来判断是否是登录接口,如果是的话生成token并且绑定用户信息。
- pre : 访问其他接口之前,先判断是否携带token,携带了则做校验(
兼容老的接
),如果符合的话则将用户信息加入到header头中,传递到其他业务服务中去。
单点登录这块的话,可以将token和用户编号做各做缓存,如果登录的时候,查看用户id 的key是否有token的值,如果有则删掉已经登录的token的key的信息。
另外,再全局写一个工具类,让其他业务服务调用的时候直接通过该工具类直接获取用户的信息,如果获取不到则直接抛出需要登录的异常。(我们对特定的异常做了包装,只会返回要登录的文本消息,免得其他小伙伴各种判断是否null啊啥的~~
)
当然如果还可以做的好一点的话是将所有微服务的路由进行管理,哪些路由是必须要通过token验证的,哪些接口需要做角色校验的、后台统一维护。而且也可以直接做到网关拦截,避免了没有携带token的也能直接过到微服务中去。
代码样本
1. 检测是否是特定的登录接口
/**
* 登录接口处理器
* 该类仅仅只是将登记的登录接口进行token绑定
*
* @author : liukx
* @time : 2020/8/19 - 14:23
*/
@Component
public class LoginPostFilter extends ZuulFilter implements InitializingBean {
private Map<String, String> loginMap = new HashMap<>();
@Autowired
private DefaultTokenFactory defaultTokenFactory;
private Logger logger = LoggerFactory.getLogger(getClass());
@Override
public String filterType() {
return FilterConstants.POST_TYPE;
}
@Override
public int filterOrder() {
return 100;
}
@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
String requestURI = ctx.getRequest().getRequestURI();
if (loginMap.containsKey(requestURI.replaceAll("//", "/"))) {
logger.info(" 是登录接口 : " + requestURI);
return true;
}
return false;
}
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
// 这里需要注意一下、如果你没有找到的话可以去ctx.getResponseDataStream()中拿,但是这个需要将流进行转换,我们应用里面在这个filter执行之前已经从ctx.getResponseDataStream()拿过一次并且放到ctx.setResponseBody(responseBody)里面了,所以下面会拿到值.
String responseBody = ctx.getResponseBody();
String managerType = loginMap.get(request.getRequestURI().replaceAll("//", "/"));
try {
if (responseBody != null) {
AbstractTokenManager abstractTokenManager = defaultTokenFactory.getTokenManager(managerType);
JSONObject resultJson = JSON.parseObject(responseBody);
Boolean success = resultJson.getBoolean("success");
if (success) {
String token = abstractTokenManager.createToken(resultJson);
ctx.addZuulResponseHeader(abstractTokenManager.getHeaderName(), token);
} else {
logger.debug(" 登录失败! ");
}
}
} catch (Exception e) {
logger.error("login filter ", e);
}
return null;
}
@Override
public void afterPropertiesSet() throws Exception {
// 登录路由和token工厂进行关联
loginMap.put("/user/login/brandXcxLogin", XcxTokenManager.tokenName);
}
}
2. 验证携带了token的header正确性
/**
* token校验的拦截器
*
* @author : liukx
* @create : 2018/6/22 14:54
* @email : liukx@elab-plus.com
*/
@Component
public class TokenFilter extends ZuulFilter {
private Logger logger = LoggerFactory.getLogger(TokenFilter.class);
@Autowired
private DefaultTokenFactory defaultTokenFactory;
/**
* 拦截器的类型
*
* @return
*/
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;
}
/**
* 拦截器的优先级,越小优先级越高
*
* @return
*/
@Override
public int filterOrder() {
return 10;
}
/**
* 是否执行该过滤器
*
* @return
*/
@Override
public boolean shouldFilter() {
return true;
}
/**
* 该拦截器执行的具体逻辑
*
* @return
*/
@Override
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
ctx.getResponse().setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
HttpServletRequest request = ctx.getRequest();
List<AbstractTokenManager> tokenManagerList = defaultTokenFactory.getTokenManagerList();
for (AbstractTokenManager abstractTokenManager : tokenManagerList) {
String headerName = abstractTokenManager.getHeaderName();
String tokenValue = request.getHeader(headerName);
if (StringUtils.isNotEmpty(tokenValue)) {
Object tokenValueObject = abstractTokenManager.checkToken(tokenValue);
if (tokenValueObject != null) {
ctx.addZuulRequestHeader(headerName, JSON.toJSONString(tokenValueObject));
// 可以告诉后端是从哪端登录的.
ctx.addZuulRequestHeader("tokenType", headerName);
logger.info("鉴权通过 : " + abstractTokenManager.toString());
break;
} else {
// 这里可以抛异常来告诉前端 token过期了
logger.debug(" token 过期或者不存在 ! ");
}
}
}
return null;
}
}
DefaultTokenFactory
这个工厂是用来管理不同token规则的,因为实际业务中可能有来自APP、小程序、WEB端的用户登录,他们返回的数据结构、token的生成和校验规则、有效时间都不同。
/**
* 默认的token创建工厂
*
* @author : liukx
* @time : 2020/8/19 - 17:17
*/
@Component
public class DefaultTokenFactory implements InitializingBean {
@Autowired
private List<AbstractTokenManager> tokenManagerList;
private Map<String, AbstractTokenManager> tokenManagerMap = new HashMap<>();
public AbstractTokenManager getTokenManager(String managerType) {
return tokenManagerMap.get(managerType);
}
public List<AbstractTokenManager> getTokenManagerList() {
return tokenManagerList;
}
@Override
public void afterPropertiesSet() throws Exception {
for (AbstractTokenManager abstractTokenManager : tokenManagerList) {
tokenManagerMap.put(abstractTokenManager.getHeaderName(), abstractTokenManager);
}
}
}
/**
* 抽象token管理类,子类可以根据自己的规则重写相应的方法
*
* @author : liukx
* @time : 2020/8/19 - 16:37
*/
public abstract class AbstractTokenManager<T> {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
protected CacheTemplate cacheTemplate;
public abstract String getHeaderName();
/**
* 校验token
*
* @param token
* @return
*/
public T checkToken(String token) {
String key = RedisKeyEnums.LOGIN_XCX_TOKEN.key(token);
T o = (T) cacheTemplate.string().get(key);
if (o != null) {
// 刷新token有效时间
cacheTemplate.getRedisTemplate().expire(key, RedisConstants.defaultExpire, TimeUnit.SECONDS);
return o;
}
return null;
}
/**
* 获取对象
*
* @param result
* @return
*/
public abstract T getObject(JSONObject result);
/**
* 生成tokenkey
*
* @return
*/
protected String generateToken(JSONObject result) {
String key = RandomUtils.randomString(3) + "_" + System.currentTimeMillis();
String token = MD5Utils.encode(key);
logger.debug("默认生成的token:" + token);
return token;
}
/**
* 默认的有效时间,如果业务有需要变更可重写
*
* @return
*/
protected Integer defaultExpire() {
return RedisConstants
.defaultExpire;
}
/**
* 创建token并加入到缓存中
*/
public String createToken(JSONObject result) {
// 创建对象
T object = getObject(result);
// 生成key的规则
String token = generateToken(result);
// 加入到缓存中
cacheTemplate.string().set(RedisKeyEnums.LOGIN_XCX_TOKEN.key(token), object, defaultExpire(), TimeUnit.SECONDS);
return token;
}
}
/**
* 小程序token管理器
*
* @author : liukx
* @time : 2020/8/19 - 16:42
*/
@Component
public class XcxTokenManager extends AbstractTokenManager<BrandXcxUserResponse> {
/**
* 处理小程序的token的header名称
*/
public static final String tokenName = HeaderConstants.HEADER_TOKEN_XCX;
@Override
public String getHeaderName() {
return tokenName;
}
@Override
public BrandXcxUserResponse getObject(JSONObject result) {
return result.getObject("single", BrandXcxUserResponse.class);
}
}
CacheTemplate 是针对RedisTemplate包装了下。
后续还需要优化的地方:
- 小程序动不动就杀死进程退出重新登录了,如果给它设置了失效时间过长,则会一个用户产生过多的token。因为每次重新登录,token都会重新生成,之前的可能还没有过期。不过用单点登录的思路可以解决。
思考中…
有好的想法或者思路可以探讨。