文章说明
本文主要为了学习 APP扫码登陆实现登陆效果的原理,该方法主要适用于 Web 端与APP端共用一个身份验证授权系统的场景;借APP端的身份以及二维码这个媒介,来实现给Web端授权的效果
比较有难度的几个小点:
1、登录流程的理解
2、如何实现APP扫描二维码
3、二维码里面放什么内容
登录流程介绍
1、APP端需要是登陆状态
2、Web端进行登陆界面,选择二维码扫描方式登录
3、后端生成登录二维码,注意,该二维码有一个唯一的二维码ID,且具有过期时间,暂定为3分钟;在Web、端二维码生成后的3分钟内,会进行轮询,来判断二维码是否已被使用且登陆成功
4、用户拿出手机端的APP,已登录后有扫码登陆的功能,扫码登陆即可,此时扫码后会弹出是否确认登陆的选项,用户选择确认登陆
5、用户确认登陆后,APP端会将二维码ID和当前的用户信息发送到授权中心,授权中心会通过APP端的登录授权来生成一个临时token,同时绑定二维码ID,将二维码状态变为已扫码
6、第3步中轮询二维码状态的操作会被监听到,二维码状态变为已扫码后,此时Web端的二维码ID派上用场,Web端拿着这个二维码ID去拿到一个临时token
7、Web端拿到临时token后,会采用这个临时token去向授权中心申请一个较长时间的接口token,这个token可以进行后续的接口访问;当然这类token都是有时限的,暂且定为3小时
采用Hutool工具包生成二维码
添加依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.6.10</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.3.3</version>
</dependency>
生成二维码,代码非常简单方便;当content为一个链接时,扫码后会自动跳转到该链接;当然本文的APP扫码,是设置为一个登陆页面的路由
@GetMapping("/generateQrCode")
public void generateQrCode(@RequestParam String content, HttpServletResponse response) throws Exception {
QrCodeUtil.generate(content, 400, 400, "png", response.getOutputStream());
}
采用Vue扫码
参考文章–Vue 移动端实现调用相机扫描二维码或条形码
不过要求电脑或者手机有摄像头才行,不然 codeReader.listVideoInputDevices() 获取到的对象长度为0,后续流程进行不了
<template>
<div class="scan-page">
<video ref="video" id="video" class="scan-video" autoplay></video>
<div v-show="data.tipShow" class="scan-tip">{{ data.tipMsg }}</div>
</div>
</template>
<script>
import {BrowserMultiFormatReader} from '@zxing/library';
import {onMounted, reactive} from "vue";
export default {
setup() {
const data = reactive({
tipShow: false,
tipMsg: '',
scanText: '',
});
onMounted(() => {
openScan();
});
let codeReader;
async function openScan() {
codeReader = await new BrowserMultiFormatReader();
codeReader.listVideoInputDevices().then(videoDevices => {
data.tipMsg = '正在调用摄像头...';
data.tipShow = true;
console.log('get-videoDevices', videoDevices);
let firstDeviceId = videoDevices[videoDevices.length - 1].deviceId;
if (videoDevices.length > 1) {
firstDeviceId = videoDevices.find(el => {
return el.label.indexOf('back') > -1 && el.label.indexOf('0') > -1
}) ? videoDevices.find(el => {
return el.label.indexOf('back') > -1 && el.label.indexOf('0') > -1
}).deviceId : videoDevices[videoDevices.length - 1].deviceId;
}
console.log('get-firstDeviceId', firstDeviceId);
decodeFromInputVideoFunc(firstDeviceId);
}).catch(err => {
data.tipShow = false;
console.error(err);
});
}
function decodeFromInputVideoFunc(firstDeviceId) {
codeReader.reset();
codeReader.decodeFromInputVideoDeviceContinuously(firstDeviceId, 'video', (result) => {
data.tipMsg = '正在尝试识别...';
if (result) {
console.log('扫码结果', result);
}
});
}
return {
data,
}
},
}
</script>
<style scoped>
.scan-page {
min-height: 100vh;
background-color: #363636;
overflow-y: hidden;
}
.scan-video {
height: 85vh;
}
.scan-tip {
width: 100vw;
text-align: center;
color: white;
font-size: 5vw;
}
</style>
简单版
由于台式机的摄像头问题,我暂时写一个简单的流程模拟版,后续补充
当前效果如下:
这里没有进行很多安全校验,会有一些问题,后续可以补充安全校验;
前端代码:App.vue
<template>
<h2 v-if="data.isLogin">当前用户身份:{{ data.username + "/" + data.password }}</h2>
<h2 v-if="!data.isLogin">请先扫码登录</h2>
<img alt="" src="" id="qrCode" @click="changeQrCode"/>
<h4>二维码倒计时:{{ data.validTime + "秒" }} <span style="margin-left: 30px">点击可刷新二维码</span></h4>
<h4 style="margin-top: 30px; cursor: pointer" @click="appEnsure">模拟手机端点击确认</h4>
</template>
<script>
import {onMounted, reactive} from "vue";
import {getRequest, postRequest} from "@/util";
export default {
setup() {
const data = reactive({
username: '',
password: '',
isLogin: false,
validTime: 300,
});
onMounted(() => {
changeQrCode();
});
function genUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0, v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
let timer;
let uuid;
function changeQrCode() {
if (timer) {
clearInterval(timer);
data.validTime = 300;
}
const qrCode = document.getElementById("qrCode");
uuid = genUUID();
qrCode.src = "http://127.0.0.1:8081/login/generateQrCode?uuid=" + uuid;
timer = setInterval(() => {
data.validTime--;
if (data.validTime === 0) {
clearInterval(timer);
console.log("二维码已过期");
}
getRequest("/login/validScan?uuid=" + uuid).then((tempTokenRes) => {
if (tempTokenRes.data.code === 200) {
clearInterval(timer);
getRequest("/login/getNewToken?tempToken=" + tempTokenRes.data.data).then((newTokenRes) => {
if (newTokenRes.data.code === 200) {
data.isLogin = true;
console.log("登陆成功");
data.username = newTokenRes.data.data[0];
data.password = newTokenRes.data.data[1];
}
});
}
})
}, 1000);
}
function appEnsure() {
if (!uuid) {
console.log("当前还未生成二维码");
return
}
postRequest("/login/appEnsure", {
uuid: uuid,
username: "BBYH",
password: "1234",
}).then((res) => {
console.log(res);
});
}
return {
data,
changeQrCode,
appEnsure,
}
},
}
</script>
<style scoped>
* {
margin: 0;
padding: 0;
text-align: center;
user-select: none;
}
h2 {
background-color: orange;
padding: 10px;
height: 50px;
line-height: 50px;
}
img {
width: 400px;
height: 400px;
display: block;
margin: 40px auto;
cursor: pointer;
}
h4 {
background-color: orangered;
padding: 10px;
height: 50px;
line-height: 50px;
}
</style>
后端代码:LoginController.java
package com.boot.controller;
import cn.hutool.core.lang.UUID;
import cn.hutool.extra.qrcode.QrCodeUtil;
import com.boot.entity.Result;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* @author bbyh
* @date 2023/12/23 11:43
*/
@RestController
@RequestMapping("/login")
public class LoginController {
private static final Map<String, String[]> USER_MAP = new HashMap<>(4);
private static final Map<String, String> TEMP_TOKEN_MAP = new HashMap<>(4);
private static final Map<String, Integer> QR_MAP = new HashMap<>(4);
private static final String CONTENT_URL = "http://127.0.0.1:8080/#/login?uuid=";
private static final Integer NOT_SCAN = 0;
private static final Integer SCANNED = 1;
@GetMapping("/generateQrCode")
public void generateQrCode(@RequestParam String uuid, HttpServletResponse response) throws Exception {
QR_MAP.put(uuid, NOT_SCAN);
QrCodeUtil.generate(CONTENT_URL + uuid, 400, 400, "png", response.getOutputStream());
}
@GetMapping("/validScan")
public Result validScan(@RequestParam String uuid) {
if (Objects.equals(QR_MAP.get(uuid), NOT_SCAN)) {
return Result.error("当前二维码未被扫描", null);
} else if (Objects.equals(QR_MAP.get(uuid), SCANNED)) {
return Result.success("当前二维码被成功扫描", TEMP_TOKEN_MAP.get(uuid));
} else {
return Result.error("当前二维码ID不存在", null);
}
}
@PostMapping("appEnsure")
public Result appEnsure(@RequestBody Map<String, String> map) {
String uuid = map.get("uuid");
String token = UUID.randomUUID().toString();
TEMP_TOKEN_MAP.put(uuid, token);
String username = map.get("username");
String password = map.get("password");
USER_MAP.put(token, new String[]{username, password});
QR_MAP.put(uuid, SCANNED);
return Result.success("APP端点击确认", null);
}
@GetMapping("/getNewToken")
public Result getNewToken(@RequestParam String tempToken) {
if (USER_MAP.get(tempToken) != null) {
return Result.success("登录成功", USER_MAP.get(tempToken));
}
return Result.error("临时token:" + tempToken + "不存在", null);
}
}
后端需要加一个跨域配置
package com.boot.config;
import com.sun.istack.internal.NotNull;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author bbyh
* @date 2023/11/19 12:32
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(@NotNull CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "DELETE", "PUT")
.maxAge(3600);
}
}
相对完整版
上述的简单页面最初只为了简单模拟扫码登陆的后端、Web端、APP端的流程;目前我添加了一个较为完整的界面来演示这个扫码登陆的效果;不过由于我目前的摄像头组件的缺失,扫码功能无法继续完成了;不过目前其余部分的功能都测试好了,效果展示如下:
页面是参考新版QQ的登录页面来书写的,仅供学习使用
Web扫码登陆
Web登陆后的信息页面
APP账密登录
APP登录后的信息页面
APP摄像头扫码页面