app扫码登录客户端的实力数不胜数,在众多的应用中被应用到的实力当中实现的方法也是层出不穷。今天分享一下我在实现app扫码登录客户端的搬砖之路,也希望这篇帖子能够帮助到更多人。因为在这个产品开发中我作为全栈开发,这个功能从前端到后台由我独自完成,所以从前端到后台我尽量写得详细一点,让更多在看我这篇文章的的码农少走弯路!
虽然目前uniapp在开发中会踩到各种坑,但是我觉得uni的开发生态上还是蛮不错的,比如在推送这一块做的非常不错,集成也便捷。同时一套代码使用不同的条件编译可以开发出多套平台应用,目前国内能有人做到这一点,我也是很佩服。
spring security基于Auth2.0的安全框架,相比Shiro要更轻巧,且搭配springboot更佳。
当然关系型数据库就是存放常规数据使用,比如用户信息等等。
既然有人在看这篇文章,我相信你一定了解redis。我在这里就不过多的阐述,但是redis在这个功能需求中还是比较重要的一环,当然你也可以不采用redis,使用其他技术实现也可以。
前端请求后台生成一个二维码并返回给前端—>前端请求到二维码后开始长轮询二维码状态—>App扫码—> App授权客户端—> 客户端轮询到二维码状态为验证后登录成功
有后端生成一个随机的UUID,然后把UUID生成二维码图片,再把生成的二维码图片转换成base64编码返回给前端由前端使用base64格式访问二维码
代码:
添加依赖:
<!-- 扫码登录生成二维码依赖-->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.4.0</version>
</dependency>
<!-- Feign表单提交模拟依赖-->
<dependency>
<groupId>io.github.openfeign.form</groupId>
<artifactId>feign-form</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>io.github.openfeign.form</groupId>
<artifactId>feign-form-spring</artifactId>
<version>3.2.2</version>
</dependency>
二维码生成工具
@GetMapping(value = "/createQrcode")
public ResultVo CreateQrCode() throws IOException {
// 生成随机的UUID
String uuid = UUIDUtil.uuid();
// 使用生成的UUID生成二维码图片的base64字符串
String qrCode = createQrCode.createQrCode(uuid,200,200);
// 保存UUID及UUID的状态到redis
redis.opsForHash().put(QrCodeStatus.UUID+uuid,"status",QrCodeStatus.NOT_SCAN);
redis.expire(QrCodeStatus.UUID+uuid,5,TimeUnit.MINUTES);
// 保存UUID和base64格式信息的二维码
redis.opsForValue().set(qrCode, uuid,5,TimeUnit.MINUTES);
return ResultUtils.success("二维码生成",qrCode);
}
/*
** 二维码生成
*/
@Service("CreateQrCode")
public class CreateQrCode {
public String createQrCode(String content, int width, int height) throws IOException {
String resultImage = "";
if (!StringUtils.isEmpty(content)) {
ServletOutputStream stream = null;
ByteArrayOutputStream os = new ByteArrayOutputStream();
@SuppressWarnings("rawtypes")
HashMap<EncodeHintType, Comparable> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "utf-8"); // 指定字符编码为“utf-8”
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); // 指定二维码的纠错等级为中级
hints.put(EncodeHintType.MARGIN, 2); // 设置图片的边距
try {
QRCodeWriter writer = new QRCodeWriter();
BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints);
BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
ImageIO.write(bufferedImage, "png", os);
/**
* 原生转码前面没有 data:image/png;base64 这些字段,返回给前端是无法被解析,可以让前端加,也可以在下面加上
*/
resultImage = new String("data:image/png;base64," + Base64.encode(os.toByteArray()));
return resultImage;
} catch (Exception e) {
e.printStackTrace();
} finally {
if (stream != null) {
stream.flush();
stream.close();
}
}
}
return null;
}
}
//获取二维码
getQrCode() {
//清除之前设置的长轮询
clearInterval(this.Timer);
//设置二维码初始状态为未扫描
this.qr_status = "NOT_SCAN";
//清空二维码url
this.qr_src = "";
this.$axios
.get("/api/qrcode/createQrcode")
.then((res) => {
var data = res.data.data;
this.qr_src = data.replace(/[\r\n]/g, "");
//二维码请求成功后开始长轮询查询二维码状态
this.checkQr();
})
.catch((err) => {
console.log(err);
});
},
<div v-if="qr_status === 'NOT_SCAN' || qr_status === 'EXPIRED'" class="qr-img">
<el-image v-if="qr_status === 'NOT_SCAN'" style="width: 200px; height: 200px" :src="qr_src" fit="cover">
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline">i>
div>
<div slot="placeholder" class="image-slot">
加载中
<span class="dot">...span>
div>
el-image>
<div v-else style="margin: 0 auto; width: 200px; height: 200px; font-size: 16px; color: #f3f3f3">
<div class="expired-title">二维码已失效div>
<el-button type="primary" style="margin: 40px auto" @click="refreshQr">刷新二维码el-button>
div>
<div class="fresh">
<el-link @click="refreshQr" :underline="false" type="primary">
<i class="el-icon-refresh-right">i>
刷新二维码
el-link>
div>
<div class="fresh">
<el-image
style="margin: auto 10px; width: 25px; height: 25px; vertical-align: middle"
:src="require('../../assets/img/scan.png')"
fit="cover"
>el-image>
打开
<div class="tips-title">数控宝Appdiv>
扫码登录
div>
div>
<div v-else-if="qr_status === 'SCANNED' || qr_status === 'VERIFIED'" class="scand">
<div class="scand-icon">
<i class="el-icon-success">i>
div>
<div class="scan-title">App已扫描成功!div>
<div class="scan-h1">请在手机上根据提示确认div>
div>
div>
当然二维码状态持续查询的方式有很多,比如websocket长连接,此方法更为便捷。但是参考了腾讯和阿里云的方案,都是采用长轮询查询。
@GetMapping("/query")
@ResponseBody
public ResultVo queryIsScannedOrVerified(@RequestParam("img")String img){
// 前端传过来的base64信息在redis查询对应的UUID
String base64Url = img.replaceAll(" ","+"); //应为base64编码中会有空格字符,空格字符在redis作为键名时空格会被替换成+,然而我在base64生成后就去空格始终无法去除,不知道哪里处理问题,所以我在这里才将空格替换为+。
//通过uuid作为键名查询UUID
String uuid = (String) redis.opsForValue().get(base64Url);
// 查询UUID对应的状态并返回给前端
String s = (String) redis.opsForHash().get(QrCodeStatus.UUID+uuid,"status");
JSONObject data = new JSONObject();
data.put("status",s);
//如果此时UUID的状态为已验证的话,就查询出uuid对应绑定的用户信息,并返回给前端
if("VERIFIED".equals(s)){
String str = (String) redis.opsForHash().get(QrCodeStatus.UUID+uuid,"info");
JSONObject object = JSON.parseObject(str);
data.put("info",object);
return object != null ? ResultUtils.success("查询成功",data) : ResultUtils.error("授权失败");
}else{
return ResultUtils.success("查询成功",data);
}
}
客户端在请求二维码成功后就要长轮询查询二维码状态
//轮询检查二维码是否过期或者是否被扫描
checkQr() {
var _this = this;
//开始长轮询
var timer = setInterval(() => {
var data = { img: this.qr_src };
//携带上当前二维码的base64编码
_this.$axios
.get("/api/qrcode/query?img=" + _this.qr_src)
.then((res) => {
var data = res.data;
if (data.code === 200) {
//返回当前二维码的状态
_this.qr_status = data.data.status;
//如果状态为EXPIRED为过期,为null也是过期,因为在二维码存入redis的时候会有一个过期时间,如果二维码过期了,Redis就不存在,所以这里状态就会返回null。当二维码过期前端提示二维码已经过期。
if (data.data.status === "EXPIRED" || data.data.status === null) {
_this.qr_src = "";
_this.qr_status = "EXPIRED";
//二维码过期就不再轮询查询二维码状态
clearInterval(timer);
} else if (data.data.status === "VERIFIED") {
//如果状态为已经授权,那么就能拿到用户授权的数据,包括用户登录token,用户id,用户权限菜单等等
var info = data.data.info;
//将用户信息存入sessionStorage
sessionStorage.setItem("token", info.token);
sessionStorage.setItem("userId", info.userId);
sessionStorage.setItem("user", info.username);
sessionStorage.setItem("deptName", info.orgName);
sessionStorage.setItem("organizationChain", info.organizationChain);
if (info.profilePath) {
if (info.profilePath.substr(0, 4) === "http") {
sessionStorage.setItem("avater", info.profilePath);
} else {
sessionStorage.setItem("avater", info.profilePath);
}
}
let menuList = info.menuList;
let routerList = info.routerList;
let authList = info.authList;
//存储菜单数据
sessionStorage.setItem("menuList", JSON.stringify(menuList));
//存储路由数据
sessionStorage.setItem("routerList", JSON.stringify(routerList));
//权限
sessionStorage.setItem("authList", JSON.stringify(authList));
clearInterval(this.Timer);
_this.$router.push({ path: "/index" });
}
}
})
.catch((err) => {
console.log(err);
});
}, 1500);
this.Timer = timer;
},
自此客户端逻辑处理完成,接下来就是App的逻辑处理和app 的后台接口
如果二维码查询状态为null,那么二维码是已经过期了,前端处理二维码状态为失效。如图:
//查询Pc登录授权UUID
queryUUID(uuid) {
var _this = this;
this.$myRequest('/api/qrcode/queryuuid?uuid=' + uuid, 'GET').then(res => {
if (res.data.code === 200 && res.data.data === "NOT_SCAN") {
_this.doScan(uuid);
} else {
_this.$refs.loading.close()
uni.navigateTo({
url: "/pages/webView/webView?url=" + uuid
})
}
}).catch(err => {
_this.$refs.loading.close()
console.log(err)
})
},
后端查询二维码状态的接口
@GetMapping("/queryuuid")
@ResponseBody
public ResultVo queryuuidVerified(@RequestParam("uuid")String uuid){
// 前端传过来的UUID
String s = (String) redis.opsForHash().get(QrCodeStatus.UUID+uuid,"status");
return ResultUtils.success("查询成功",s);
}
如果状态为未扫描,就告诉后端我已经扫描二维码了,把二维码状态修改为已扫描,并且进入授权页面。
//告诉PC端我已经扫描
doScan(uuid) {
var _this = this;
_this.$myRequest('/api/qrcode/doScan?uuid=' + uuid).then(res => {
if (res.data.code === 200) {
_this.$refs.loading.close()
//接下来就是进入App授权页面
uni.navigateTo({
url: "/pages/home/maintenance/authToPc/authToPc?uuid=" + uuid,
animationType: "slide-in-top",
animationDuration: 500
})
}
}).catch(err => {
_this.$refs.loading.close()
})
},
只要App通知后端就要改变二维码的状态,修改为已扫描,客户端长轮询就会查询到二维码状态为已扫描。
/**
* app扫描接口
* @param uuid
* @return
*/
@GetMapping("/doScan")
@ResponseBody
public ResultVo doAppScanQrCode(@RequestParam("uuid")String uuid){
//查询二维码当前状态,如果查询为空,二维码就已经过期,告诉app二维码已经过期
String status = (String) redis.opsForHash().get(QrCodeStatus.UUID+uuid,"status");
if(status.isEmpty()) return ResultUtils.error("UUID状态查询出错");
switch (status){
case "NOT_SCAN":
//等待确认 todo
//修改二维码状态为已扫描SCAND
redis.opsForHash().put(QrCodeStatus.UUID+uuid,"status",QrCodeStatus.SCANNED);
redis.expire(QrCodeStatus.UUID+uuid,5,TimeUnit.MINUTES);
return ResultUtils.success("扫码成功");
case "SCANNED":
return ResultUtils.error("二维码已扫描");
case "VERIFIED":
return ResultUtils.error("已验证二维码");
}
return ResultUtils.error("二维码验证错误");
}
App查询到二维码有效且告诉了后盾我已经扫描后就进入授权页面
当App端点击确认登录时,将App登录用户的id、token和uuid带上,访问授权接口。
//授权登录访问
comformLogin(){
var _this = this;
//从Appstorage中取出uid和token
var uid = uni.getStorageSync("userId");
var token = uni.getStorageSync("token");
uni.showLoading({
title:"正在确认中"
})
this.$myRequest('/api/qrcode/verify/'+uid+'/'+token+'/'+this.uuid).then(res=>{
console.log(res)
uni.hideLoading()
if(res.data.code === 200){
uni.navigateBack({
delta:1,
animationType:"slide-out-bottom"
})
}else{
uni.showToast({
title:"系统错误,稍后再试",
icon:"none"
})
}
}).catch(err=>{
uni.hideLoading()
uni.showToast({
title:"网络错误,稍后再试",
icon:"none"
})
})
}
授权控制器通过token获取出用户名,再用用户名拿到redis中存的用户信息解密出用户名和密码。拿到用户名的用户名和密码后,通过解密密码后调用Feign调用用户登录接口。拿到登录接口返回的信息后通过redis将信息已二维码为键名,用户登录信息为值存入redis,且二维码的状态设置为已授权。
/**
* app确认登录接口
* @param uuid
* @return
*/
@GetMapping("/verify/{uid}/{token}/{uuid}")
@ResponseBody
public ResultVo verifyQrCode(@PathVariable("uid") String uid, @PathVariable("token") String token, @PathVariable("uuid") String uuid) throws Exception {
//去除当前二维码的状态
String status = (String) redis.opsForHash().get(QrCodeStatus.UUID+uuid,"status");
if(status.isEmpty()) return ResultUtils.error("二维码验证失败");
// 根据token获取用户名
String username = jwtUtils.getUsernameFromToken(token);
//取出用户信息,因为用户存在redis中的信息的键名为用户名。
String userInfo = (String) redis.opsForHash().get("user::"+username+"::mo","info");
JSONObject userObj = JSONObject.parseObject(userInfo);
// 拿到user对象后就可以拿到用户密码并解密成MD5
HexUtils hexUtils = new HexUtils();
RSACoder rsaCoder = new RSACoder();
// 通过token拿到用户名和用户解密出用户密码后可以使用户名密码登录方式登录
String password = hexUtils.bytesToHex(rsaCoder.decryptByPublicKey(hexUtils.hexToByteArray(userObj.getString("password")), KeyCode.PUBLIC_KEY));
// 构建一个空用户对象,然后使用这个用户对象去调用登录接口。
MultiValueMap<String,Object> param = new LinkedMultiValueMap<>();
param.add("username",username);
param.add("password",password);
JSONObject object = qrCodeFeign.post(param);
if(object.getInteger("code") == 200){
// 将二维码绑定的uuid状态修改为已验证
redis.opsForHash().put(QrCodeStatus.UUID+uuid,"info",JSON.toJSONString(object.getJSONObject("data")));
redis.opsForHash().put(QrCodeStatus.UUID+uuid,"status",QrCodeStatus.VERIFIED);
redis.expire(QrCodeStatus.UUID+uuid,5,TimeUnit.MINUTES);
return ResultUtils.success("确认成功");
}else{
return ResultUtils.error("授权失败");
}
}
调用用户使用用户名和密码登录接口返回登录token和用户权限等信息。
@Service
@FeignClient(name = "nn-rbac-web",contextId = "nn-auth-user")
public interface QrCodeFeign {
@PostMapping(value = "/api/user/login",
consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE},
produces = {MediaType.APPLICATION_JSON_UTF8_VALUE}
)
JSONObject post(Map<String, ?> queryParam);
class FormSupportConfig {
@Autowired
private ObjectFactory<HttpMessageConverters> messageConverters;
// new一个form编码器,实现支持form表单提交
@Bean
public Encoder feignFormEncoder() {
return new SpringFormEncoder(new SpringEncoder(messageConverters));
}
// 开启Feign的日志
@Bean
public Logger.Level logger() {
return Logger.Level.FULL;
}
}
}
此时客户端查询二维码状态的长轮询就查询到二维码已经授权,且用户授权信息已经存到redis,如果状态为已授权,则会连同授权信息一起返回给前端,前端代码上面已经贴出来了,这里放上截图。
长轮询发现二维码已经授权就接着走登录成功的流程
这样扫码登录的流程就算结束了,关键代码已经贴出,就不奉献源码了,毕竟是公司商业项目。因为没有经常写博客,文采疏浅请见谅,如有看不懂的地方欢迎骚扰我。
如果觉得对您很有用出的话,打发点茶水费。