授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。
这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

需求背景

虽然,我们项目客户大部分为pc端操作,当客户要把手机拍中照片,要上传至系统中,如果APP没有提供专门的接口,客户需要先把照片传到电脑中,然后通过网页上传,这样比较麻烦,于是,产品便提出需要开发扫码上传附件的功能。

流程图

注册服务
image.png
上传附件
image.png

功能设计

要实现扫码上传附件的功能,
前端功能

  1. 请求扫描上传附件的申请接口,返回上传地址,生产二维码
  2. 首先用户扫描二维码后,跳转到网页,点击上传吊起手机选择相册或者拍照的功能,然后选择上传附件,最后再提交

后端功能

  1. 业务发起申请,后端接受到请求后,注册当前申请的信息,然后获取到一个上传地址
  2. 用户提交功能,需要根据请求类型,找到相应业务实现类,将之前申请业务的id和当前附件id都交给实现类

从这个几个流程,可以看出几个要点,授权机制业务处理

授权机制

二维码生成

关于授权机制,我模仿了Oauth2 的授权码的模式。首先由业务发送申请,后端根据请求的生成一个临时的UUID,以UUID为key,请求中的类型、业务的id,保存至Redis缓存,时效性大概30分钟左右,将上传附件的地址和UUID合并,返回给接口,后面业务又提出一个静态二维码的问题,即保存在文件中的二维码,于是又增加一个注册静态二维码的接口,这里只是把生成的UUID,保存到库

源码

 /**
上传附处理类这个类即接受参数,也做为返回参数,严格来说,需要分类两个类,一个是参数类,一个结果类
 */
@Data
public class FileByQRCodeDTO implements Serializable {
    /**
     * 记录的id
     */
    private String recordId;
    /**
     * 类型
     */
    private String type;
    /**
     * 文件的id
     */
    private String files;
    /**
     * 用户的token
     */
    private String token;
    /**
     * 上传限制
     */
    private Integer maxCount;
    /**
     * 临时的用户凭证
     */
    private String temporaryUuid;
}

 /**
     *  ITjFileInfoService:: getUploadFileQRCodeRedirectUrl
     *  <p>TO:获取二维码跳转的地址
     *  <p>HISTORY: 2021/1/18 liuha : Created.
     *  @param    fileByQRCodeDTO  请求参数
     * @return   String 上传的地址
     */
   public String getUploadFileQRCodeRedirectUrl(FileByQRCodeDTO fileByQRCodeDTO) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        String callbackUrl = "";
        try {
            callbackUrl = 		URLEncoder.encode(uploadFileQRCode,"utf8");
            callbackUrl=String.format(uploadFileQRCode,uuid);
//保存至redis
            redisUtil.set(uuid,fileByQRCodeDTO,3600);
        } catch (UnsupportedEncodingException e) {
           throw  new JeecgBootException("生成上传文件的地址失败");
        }
        return callbackUrl;
    }

 /**
     * ITjFileInfoService::
     * <p>TO:获取静态二维码的上传地址
     * <p>HISTORY: 2021/1/30 liuha : Created.
     *
     * @param fileByQRCodeDTO 请求参数
     * @return String  静态二维码的地址
     */
    @Override
    public String getStaticQRCodeRedirectUrl(FileByQRCodeDTO fileByQRCodeDTO) {
        String uuid = UUID.randomUUID().toString().replaceAll("-", "");
        String callbackUrl = "";
        try {
//uploadFileQRCode 跳转到上传附件界面的地址 比如 //http://localhost:8081/uploadqrcodefile/
            callbackUrl = URLEncoder.encode(uploadFileQRCode,"utf8");
            callbackUrl=String.format(uploadFileQRCode,uuid);
            //将关联信息存入至关联表中
            TjFileQrcodeLink  fileQrcodeLink =new TjFileQrcodeLink();
            fileQrcodeLink.setRecordId(fileByQRCodeDTO.getRecordId());
            fileQrcodeLink.setType(fileByQRCodeDTO.getType());
            fileQrcodeLink.setUuid(uuid);
            fileQrcodeLinkService.save(fileQrcodeLink);
        } catch (UnsupportedEncodingException e) {
            throw  new JeecgBootException("生成上传文件的地址失败");
        }
        return callbackUrl;
    }

业务处理

这里处理主要是后端如何把上传后的附件id给相应的业务实现类,我在这块这里设计时,采用了策略模式,即,我把处理接口定义好,由业务实现接口,我根据接口定义的type,找到相应的实现类bean对象,把附件id和业务id交给业务处理,这个在我之前写的文章提过类似的实现方式,项目重构:设计模式(策略模式)

源码

//上传接口定义
public interface IQRCodeUpload {

    /**
     *  IQRCodeUpload:: getType
     *  <p>TO:获取实现类的类型
     *  <p>HISTORY: 2021/1/18 liuha : Created.
     *  @return   String  
     */
    String getType();


    /**
     *  IQRCodeUpload:: handleUploadFile
     *  <p>TO:上传附件的后处理接口
     *  <p>HISTORY: 2021/1/18 liuha : Created.
     *  @param    recordId  记录id
     *  @param    files  附件的id,以逗号隔开的方式传递
     */
    void handleUploadFile(String recordId,String files);
}
//将实现类的在初始化spring完成后,加载到map中
@Component
public class UploadQRCodeFactory implements InitializingBean, ApplicationContextAware {
    private ApplicationContext appContext;

    public static final
    Map<String, IQRCodeUpload> UPLOAD_IMPL_MAP = new HashMap<>(16);


    /**
     * UploadContextConfig:: getHandler
     * <p>TO:通过type获取IQRCodeUpload的实现类
     * <p>HISTORY: 2021/1/19 liuha : Created.
     *
     * @param type 实现类的的类型
     * @return IQRCodeUpload  实现类
     */
    public IQRCodeUpload getHandler(String type) {
        return UPLOAD_IMPL_MAP.get(type);
    }

    @Override
    public void afterPropertiesSet() {
        appContext.getBeansOfType(IQRCodeUpload.class)
                .values()
                .forEach(handler -> UPLOAD_IMPL_MAP.put(handler.getType(), handler));
    }


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        appContext = applicationContext;
    }
}

调用业务实现类的接口

java
  /**
     *  ITjFileInfoService::submitFileByQRCode
     *  <p>TO:上传二维码上传文件
     *  <p>HISTORY: 2021/1/19 liuha : Created.
     *  @param    fileByQRCodeDTO  需要上传的文件
*/
 public void submitFileByQRCode(FileByQRCodeDTO fileByQRCodeDTO) {
        String type = fileByQRCodeDTO.getType();
        if(!StringUtils.isEmpty(type)){
            //获取业务的实现类
            IQRCodeUpload handler = handlerFactory.getHandler(type);
            if (handler == null) {
                throw  new JeecgBootException("未找到提交处理的实现类");
            }
            handler.handleUploadFile(fileByQRCodeDTO.getRecordId(),fileByQRCodeDTO.getFiles());
        }
    }

获取授权TOEKN处理

这里主要为了处理上传附件的接口需要token的认证,一开始,考虑到如果把上传附件的接口去掉token的限制,但是会带来一个问题,如果接口被泄露,会被恶意利用上传附件,所以还是要考虑处理授权的问题,如果是动态的二维码其实比较好处理,就是在缓存中保存当时申请的用户的token,请求时,返回给前端即可,但是静态的二维码,缓存中保存当时申请的用户的token的方式,则失去了时效性。
于是对框架做了部分的调整
1.首先通过uuid授权时候,先查询动态库是否含有当前uuid,对应的id,不存在的时则去查询静态的注册库,查找到信息后,生成一个临时token
2.在验证token时,当发现前缀是uploadFile,则特殊处理

@ApiOperation(value = "通过uuid获取二维码的信息", notes = "通过uuid获取二维码的信息")
    @GetMapping(value = "/getqrcodeinfo")
    public Result<?> getQRCodeInfo(@RequestParam(name = "uuid", required = true)
                                                    String uuid) {
        uuid=  StringUtils.lowerCase(uuid);
        FileByQRCodeDTO fileByQRCodeDTO= (FileByQRCodeDTO) redisUtil.get(uuid);
        //如果为空,查询静态库
        if (fileByQRCodeDTO == null) {
            LambdaQueryWrapper<TjFileQrcodeLink> query= new LambdaQueryWrapper();
            query.eq(TjFileQrcodeLink::getUuid,uuid);
            TjFileQrcodeLink fileQrcodeLink = fileQrcodeLinkService.getOne(query);
            if (fileQrcodeLink != null) {
                fileByQRCodeDTO =new FileByQRCodeDTO();
                fileByQRCodeDTO.setRecordId(fileQrcodeLink.getRecordId());
                fileByQRCodeDTO.setType(fileQrcodeLink.getType());
//生成一个临时的用户密码
                String temporaryUuid = UUID.randomUUID().toString().replaceAll("-", "");
                //生成临时token
                String  token =JwtUtil.sign("uploadFile"+uuid,temporaryUuid);
                fileByQRCodeDTO.setToken(token);
                redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token);
                redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, JwtUtil.EXPIRE_TIME*2 / 1000);
                fileByQRCodeDTO.setMaxCount(fileQrcodeLink.getMaxCount());
                fileByQRCodeDTO.setTemporaryUuid(temporaryUuid);
                redisUtil.set(uuid,fileByQRCodeDTO,3600);
            }
        }
        return Result.ok(fileByQRCodeDTO);
    }

修改ShiroRealm验证的地方

}else if(username.startsWith("uploadFile")){
            String[] uploadFiles = username.split("uploadFile");
            FileByQRCodeDTO fileByQRCodeDTO= (FileByQRCodeDTO) redisUtil.get(uploadFiles[1]);
            loginUser.setUsername(username);
            loginUser.setPassword(fileByQRCodeDTO.getTemporaryUuid());
            loginUser.setStatus(1);
        }

这块地方使用红色提醒,主要不建议使用这段代码,由于静态二维码是后面提出来的,一开始设计没考虑到二维码的类型不同,请求不同授权接口,所以我对这块代码,很不喜欢,一、代码不规范写法;二、接口功能糅合了两个处理方式,可读性差

前端界面

前端,我采用了有赞的vant-vue框架,贴上核心上传界面的代码,
image.png

整个前端的界面交互都在整个页面上,获取授权、上传附件、提交附件
UploadFile.vue

<template>
	<div style="height: 100%">

		<div v-show="uploadDiv">
			<van-grid :column-num="3" :gutter="20">

				<van-uploader preview-size="120" :max-count="maxCount" v-model="fileList" multiple :after-read="afterRead" />


			</van-grid>
			<van-tabbar>

				<van-button size="large" type="primary" @click="submitFile()">提交</van-button>

			</van-tabbar>


		</div>

		<div v-show="success" class="success-div">
			<div>
				<van-icon name="success" size="5em" color="#00aa00" />
				<div>提交成功</div>
			</div>
		</div>
		<div v-show="fail" class="success-div">
			<div>
				<van-icon name="fail" size="5em" color="#ff0000" />
				<div>操作失败</div>
			</div>
		</div>
		<van-overlay :show="show" />
	</div>


</template>
<script>
	import {
		setToken
	} from '../api/auth.js'

	import {
		Toast
	} from 'vant';
	export default {
		name: 'app',
		components: {
			setToken,

		},
		data() {
			return {
				fileList: [],
				recordId: "",
				type: "",
				token: "",
				show: false,
				uploadDiv: true,
				success: false,
				fail: false,
				maxCount: 9
			};
		},
		mounted() { //页面初始化方法
			this.getUrlParameter();
		},
		methods: {
			submitFile() {
				var fileList = this.fileList;
				console.log(fileList);
				var files = [];
				fileList.forEach(function(item) {
					if (item.fileId) {
						files.push(item.fileId);
					}
				})
				if (files.length == 0) {
					this.$notify('未上传附件');
					return;
				}
				var data = {
					recordId: this.recordId,
					type: this.type,
					files: files.join(",")
				};

				this.$toast.loading({
					message: '提交中...',
					forbidClick: true,
				});

				this.$api.post('/file/tjFileInfo/submitfilebyqrcode', data,
					response => {
						this.$toast.clear();
						if (response.status == 200) {
							if (response.data.code == 200) {
								this.$toast.success('提交成功');
								this.uploadDiv = false
								this.success = true

							} else {
								this.$toast.fail('提交失败');
								this.uploadDiv = false
								this.fail = true
							}

						} else {
							this.$notify('提交失败');
							this.uploadDiv = false
							this.fail = true
						}
					});

			},
			getUrlParameter() {
				var url = window.location.href;
				var dz_url = url.split('#')[0];

				var cs = dz_url.split('?')[1];

				var cs_arr = cs.split('&');

				var cs = {};
				for (var i = 0; i < cs_arr.length; i++) { //遍历数组,拿到json对象
					cs[cs_arr[i].split('=')[0]] = cs_arr[i].split('=')[1]
				}
				var that = this;
				this.$api.get('/file/tjFileInfo/getqrcodeinfo', {
						uuid: cs.uuid
					},
					response => {
						if (response.status == 200) {
							if (response.data.code == 200) {
								if (response.data.result) {
									that.recordId = response.data.result.recordId;
									that.type = response.data.result.type;
									that.token = response.data.result.token;
									if (response.data.result.maxCount > 0) {
										that.maxCount = response.data.result.maxCount;
									}

									setToken(that.token);
								} else {
									this.$notify('连接已失效,请重新获取二维码');
									that.show = true;
								}
							} else {
								this.$notify('获取用户信息失败');
								that.show = true;
							}
						} else {
						this.$notify('获取用户信息失败');
						that.show = true;
					}
				});


		},
		uploadFile(file) {
			let formData = new FormData()
			//上传文件到上传至服务器
			formData.append('file', file.file)
			file.status = 'uploading';
			file.message = '上传中...';
			this.$api.post('/sys/commonBase/upload', formData,
				response => {
					if (response.status == 200) {
						if (response.data.code == 0) {
							file.status = 'done';
							file.fileId = response.data.result;

						} else {
							file.status = 'failed';
							file.message = '上传失败...';
						}

					} else {
						file.status = 'failed';
						file.message = '上传失败...';
					}
				});
		},
		afterRead(files) {
			var that = this;
			if (files.length) {

				files.forEach(function(file) {
					that.uploadFile(file);
				})
			} else {
				that.uploadFile(files);
			}

		},
	},
	};
</script>

<style>
	.success-div {
		height: 100%;
		display: flex;
		display: -webkit-flex;
		align-items: center;
		justify-content: center;
	}
</style>

总结

扫描上传附件的功能,从开始到整个模块开发完,尤其当前后端都是我一个独立完成时,还是有很大的成就感,整个功能还算比较独立,由于策略模式的加入,也变得更加灵活,耦合性降低,有了小程序开发的经验,所以前端的界面开发上手比较快、后端,模仿了授权码的机制。但是有设计上的不足,尤其静态和动态二维码的获取授权这块,还是要多练习、多思考。