链接(二维码)分享需求
-
功能模块添加分享链接(二维码)功能,通过分享出去的链接,可查看功能模块的详情。
-
分享出去的链接在1天/3天/7天/30天/永不过期,打开过期的链接,弹出提示页面链接已过期。
-
3某些模块分享的链接只能由系统内的指定用户打开,在其他系统外或者非指定的用户打开提示无权限。
-
支持
Android/Ios/Web
端分享,在Android/Ios
端内扫描二维码直接跳转至相应功能模块
程序设计方案
数据库脚本
CREATE TABLE `url_share` (
`id` varchar(32) NOT NULL COMMENT '主键',
`userSn` varchar(10) NOT NULL COMMENT '发起分享人',
`expire` bigint(20) NOT NULL COMMENT '过期时间,-1代表永久',
`shareParam` longtext NOT NULL COMMENT '分享参数',
`shareModule` varchar(20) NOT NULL COMMENT '所属模块',
`shareToken` varchar(32) NOT NULL COMMENT '分享token',
`shareUrl` varchar(512) NOT NULL COMMENT '分享的链接',
`shareTime` datetime NOT NULL COMMENT '分享时间',
PRIMARY KEY (`id`),
UNIQUE KEY `ix_shareToken` (`shareToken`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='链接分享';
CREATE TABLE `url_share_userauth` (
`id` varchar(32) NOT NULL,
`userSn` varchar(10) NOT NULL COMMENT '用户通行证',
`shareId` varchar(32) NOT NULL COMMENT '分享id',
PRIMARY KEY (`id`),
KEY `ix_shareId` (`shareId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='链接分享用户授权';
复制代码
接口新开Or复用已有接口
事先与客户端约定所有用于分享链接(二维码)的接口request uri
前添加/share
前缀作为分享接口的标识
1.新增/share/xxx
的接口
比如现有业务依赖/doBiz
接口,需要实现分享功能
@PostMapping("/doBiz")
public void doBiz(@RequestParam String param) throws Exception {
helloService.doBiz(param);
}
复制代码
新增一个用于分享的接口,定义为/share/doBiz
,然后复用service
的方法
@PostMapping("/share/doBiz")
public void shareDoBiz(@RequestParam String param) throws Exception {
helloService.doBiz(param);
}
复制代码
每有一个新模块需要分享功能,在控制层controller
要增加一个或者多个/share/xxx
的接口,造成代码重复。试想在不同的版本迭代过程中,都会存在模块添加分享功能的需求,到时候再去增加一个或者多个/share/xxx
的接口,这是很难接受的。
2.篡改请求复用已有接口
Servlet
的Filter
或者Spring Cloud
的ZuulFilter
允许我们在收到请求真正转发给ServerletDispatcher
之前修改HttpServerletRequest
的request uri
和request param
,下面是一个通过Spring Cloud
的ZuulFilter
篡改请求的例子。
2.1与前端约定所有分享页面调用业务接口的格式为:
Get(Post) /share/doBiz...
复制代码
2.2配置可用于通过/share/xxx
访问的接口uri
如果将所有接口都允许通过/share/xxx
的形式暴露出去,这是非常严重的系统漏洞,对于业务数据敏感的业务可能会带来无法挽回的损失,我们可以通过配置文件给每个模块配置允许通过/share/xxx
访问的接口,这样在每次需要给新模块添加分享功能时,仅仅需要添加配置文件,对于不符合配置模块请求uri的接口,跳过篡改请求参数(地址)的Filter继续执即可。
配置文件urshare.json
[
{
"htmlUrl":"http://172.16.1.133:9529/#/share",
"reqUrls":[
"/xxxx/task/findTaskType/**",
"/xxx/task/taskDetail/**"
],
"module":"taskDetail"
},
{
"htmlUrl":"http://172.16.1.133:9529/#/share",
"reqUrls":[
"/xxx/user/info/**"
],
"module":"userInfo"
}
]
复制代码
- htmlUrl:生成的分享的链接地址前缀,最终生成的分享链接形式一般为http://172.16.1.133:9529/#/share?shareToken=xxx
- reqUrls:分享链接的页面需要请求的后台uri地址
- module:分享模块
2.3匹配uri
是否符合规则的方法
public static boolean pathMatchPattern(String path, List<String> patterns) {
boolean result = false;
for (String pattern : patterns) {
//Spring提供的用于匹配uri正则的工具类
AntPathMatcher matcher = new AntPathMatcher();
if (matcher.match(pattern, path)) {
result = true;
break;
}
}
return result;
}
复制代码
篡改HttpServletRequest
请求和校验/share/doBiz
请求网关ZuulFilter
@Component
public class UrlShareFilter extends ZuulFilter implements ApplicationRunner {
private Logger logger = LoggerFactory.getLogger(UrlShareFilter.class);
@Resource
private RedisUtil redisUtil;
@Resource
private UrlShareFeignService urlShareFeignService;
@Override
public String filterType() {
return PRE_TYPE;
}
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
String reqUri = ctx.getRequest().getRequestURI();
if (reqUri.indexOf("/share/") != -1) {
try {
return PathUtils.pathMatchPattern(reqUri.replaceFirst("/share", EmptyUtils.EMPTY_STR), urlShareFeignService.shareReqUrls());
} catch (Exception e) {
logger.error("urlShareFeignService.shareReqUrls error", e);
return false;
}
}
return false;
}
@Override
public Object run() throws ZuulException {
//解析并验证shareToken
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
String shareToken = null;
Map<String, String[]> queryParamMap = request.getParameterMap();
if (EmptyUtils.isNotEmpty(queryParamMap)) {
String[] queryParam = queryParamMap.get(Constants.SHARE_TOKEN_HEADER);
if (EmptyUtils.isNotEmpty(queryParam)) {
shareToken = queryParam[0];
}
}
String usrToken = null;
if (EmptyUtils.isEmpty(shareToken)) {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接不合法");
return false;
} else {
UrlShareInfo urlShareInfo = null;
try {
urlShareInfo = urlShareFeignService.getUrlShareInfo(shareToken);
} catch (Exception ex) {
sendResp(ctx, HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务内部错误");
return false;
}
if (urlShareInfo == null) {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接无效");
return false;
}
if (urlShareInfo.getExpire() > 0&& urlShareInfo.getExpire() <= System.currentTimeMillis()) {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "分享链接已过期");
return false;
}
if (EmptyUtils.isNotEmpty(ctx.getRequest().getHeader(Constants.TOKEN_HEADER))) {
usrToken = ctx.getRequest().getHeader(Constants.TOKEN_HEADER);
}
if (EmptyUtils.isNotEmpty(urlShareInfo.getAuthUserSns())) {
if (usrToken == null) {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
return false;
} else {
//分享链接需要用户权限打开
if (redisUtil.get(usrToken) != null) {
UserSession userSession = JSONObject.parseObject(redisUtil.get(usrToken).toString(), UserSession.class);
if (userSession != null) {
if (!urlShareInfo.getAuthUserSns().contains(userSession.getUserSn())) {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
return false;
}
} else {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
return false;
}
} else {
sendResp(ctx, HttpStatus.UNAUTHORIZED.value(), "用户无权限");
return false;
}
}
}
}
//share请求重定向到正常请求
final String realToken = (usrToken == null ? Constants.QRCODE_SHARE_REDIS_KEY : usrToken);
String url = request.getRequestURI().replaceFirst("/share", EmptyUtils.EMPTY_STR);
ctx.setRequest(new HttpServletRequestWrapper(request) {
@Override
public String getRequestURI() {
return url;
}
@Override
public Map<String, String[]> getParameterMap() {
return queryParamMap;
}
@Override
//设置用于分享的Cookie(Token)参数,访问后台接口使用
public String getHeader(String name) {
if (name.equals(Constants.TOKEN_HEADER) || name.equals(WpsConst.HEAD_TOKEN)) {
return realToken;
}
return super.getHeader(name);
}
});
Map<String, List<String>> requestQueryParams = ctx.getRequestQueryParams();
if (requestQueryParams == null) {
requestQueryParams = new HashMap<>();
}
requestQueryParams.remove(Constants.SHARE_TOKEN_HEADER);
ctx.setRequestQueryParams(requestQueryParams);
ctx.put(FilterConstants.REQUEST_URI_KEY, url);
ctx.addZuulRequestHeader(Constants.TOKEN_HEADER, realToken);
return true;
}
private void sendResp(RequestContext ctx, Integer code, String errorMsg) {
ctx.setSendZuulResponse(false);
try {
ctx.setResponseStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value());
ctx.getResponse().setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
ctx.getResponse().getWriter().write(JSONObject.toJSONString(ResponseResult.fail(code, errorMsg)));
} catch (Exception e) {
logger.info(logger.toString());
}
}
@Override
public void run(ApplicationArguments args) throws Exception {
//初始化用于分享用到的Session数据
redisUtil.set(Constants.QRCODE_SHARE_REDIS_KEY, Constants.QRCODE_SHARE_REDIS_VAL);
}
}
复制代码
- 由于部分接口访问需要获取用户信息,先通过
ApplicationRunner.run
初始化分享Cookie(Token)
的Session
数据 shouldFilter
方法用于判断/share/doBiz
请求,是否允许经过UrlShareFilter
篡改请求uri和参数,判断逻辑:匹配请求是否符合urlshare.json
配置的reqUrls
其中的一条uri
规则,是的话就需要通过run
方法篡改请求。run
方法根据shareToken
查找此次分享的参数信息,如链接时效性 有效性 授权人并校验,其次篡改RequestUri
去除/share
前缀和requestParam
,添加用于分享用的Cookie(Token)
信息- 注意
UrlShareFilter
的优先级应该配置最高优先级
生成二维码
使用hutool
工具包的QrCodeUtil
类创建二维码并返回给客户端
@PostMapping(value = "/shareUrlQrcode", produces = "application/octet-stream;charset=UTF-8")
public void shareUrlQrcode(@RequestBody GetShareUrlParam getShareUrlParam) throws Exception {
try {
HttpServletResponse response = getResponse();
response.setHeader("Pragma", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/png");
String shareUrl = urlShareService.shareUrl(getShareUrlParam, getUserSn());
QrConfig qrConfig = QrConfig.create().setWidth(500).setHeight(500).setMargin(0).setImg(ImageIO.read(ResourceUtil.getStream("qrcodelog.png")));
QrCodeUtil.generate(shareUrl, qrConfig,"png", response.getOutputStream());
}catch (Exception e){
logger.error("shareUrlQrcode error,getShareUrlParam={}", getShareUrlParam, e);
throw e;
}
}
复制代码
总结
生成二维码最好不要将过期时间/授权用户信息直接加密放到requestParam
参数传递,因为参数大小的不确定性将会导致二维码非常密集,相机在扫描密集二维码的效果会变得很差很差。
通过shareToken
,后端交由UrlShareFilter
根据shareToken获取校验过期时间/授权用户;前端可以通过shareToken
参数调用接口得到分享页面所需的参数信息。并且二维码链接的长度确定,二维码的扫描性能得到了保证。
近期评论