引言
在前端工程中,输入验证码是一个很常见的功能,之前有各种奇形怪状的字符验证码,到后面的图形验证码。这个东西其实只是为了证明你是个人而不是机器操作的电脑,顺便降低一下登录频率。
具体而言都是使用canvas绘画,目前有两种方案
方案一:
后端介入,图形的缺口和带缺口的卡片由后端提供,然后前端把移动距离发给后端进行校验,代码如下
<template>
<div id="slideVerify" class="slide-verify" :style="widthlable" onselectstart="return false;">
<!-- <img ref="image" style="border: 1px solid #f4f4f4" :src="miniimgurl" @load="initImg()" /> -->
<canvas ref="canvas" :width="w" :height="h" />
<canvas ref="block" class="slide-verify-block" :width="w" :height="h" />
<div class="slide-verify-refresh-icon el-icon-refresh" @click="refresh" />
<div class="slide-verify-info" :class="{fail: fail, show: showInfo}" @click="refresh">{{ infoText }}</div>
<div
class="slide-verify-slider"
:style="widthlable"
:class="{'container-active': containerActive, 'container-success': containerSuccess, 'container-fail': containerFail}"
>
<div class="slide-verify-slider-mask" :style="{width: sliderMaskWidth}">
<!-- slider -->
<div
class="slide-verify-slider-mask-item"
:style="{left: sliderLeft}"
@mousedown="sliderDown"
@touchstart="touchStartEvent"
@touchmove="touchMoveEvent"
@touchend="touchEndEvent"
>
<div class="slide-verify-slider-mask-item-icon el-icon-s-unfold" />
</div>
</div>
<span class="slide-verify-slider-text">{{ sliderText }}</span>
</div>
</div>
</template>
<script>
function sum (x, y) {
return x + y
}
function square (x) {
return x * x
}
export default {
name: 'SlideVerify',
props: {
// block length
l: {
type: Number,
default: 42
},
// block radius
r: {
type: Number,
default: 10
},
// canvas width
w: { // 背景图宽
type: [Number, String],
default: 320
},
// canvas height
h: { // 背景图高
type: [Number, String],
default: 160
},
// canvas width
sw: { // 小图宽
type: [Number, String],
default: 50
},
// canvas height
sh: {
type: [Number, String],
default: 50
},
// block_x: {
// type: Number,
// default: 155
// },
blocky: { // 小图初始的垂直距离
type: [Number, String],
default: 20
},
sliderText: {
type: String,
default: 'Slide filled right'
},
imgurl: { // 大图地址
type: String,
default: ''
},
miniimgurl: { // 小图地址
type: String,
default: ''
},
fresh: {
type: Boolean,
default: false
}
},
data () {
return {
containerActive: false, // container active class
containerSuccess: false, // container success class
containerFail: false, // container fail class
canvasCtx: null,
blockCtx: null,
block: null,
canvasStr: null,
// block_x: undefined, // container random position
// blocky: undefined,
L: this.l + this.r * 2 + 3, // block real lenght
img: undefined,
originX: undefined,
originY: undefined,
isMouseDown: false,
trail: [],
widthlable: '',
sliderLeft: 0, // block right offset
sliderMaskWidth: 0, // mask width
dialogVisible: false,
infoText: '验证成功',
fail: false,
showInfo: false
}
},
watch: {
fresh (val) {
if (val) {
this.init()
}
}
},
mounted () {
// 随机生成数this.block_x =
this.init()
},
methods: {
init () {
this.initDom()
this.bindEvents()
this.widthlable = 'width:' + this.w + 'px;'
},
initDom () {
this.block = this.$refs.block
this.canvasStr = this.$refs.canvas
this.canvasCtx = this.canvasStr.getContext('2d')
this.blockCtx = this.block.getContext('2d')
this.initImg()
},
initImg (h) {
var that = this
const img = document.createElement('img')
img.onload = onload
img.onerror = () => {
img.src = that.imgurl
}
img.src = that.imgurl
img.onload = function () {
that.canvasCtx.drawImage(img, 0, 0, that.w, that.h)
}
this.img = img
const img1 = document.createElement('img')
var blockCtx = that.blockCtx
var blocky = h || that.blocky
if (blocky === 0) {
return
}
img1.onerror = () => {
img1.src = that.miniimgurl
}
img1.src = that.miniimgurl
img1.onload = function () {
// blockCtx.drawImage(img1, 0, blocky, that.sw, that.sh)
blockCtx.drawImage(img1, 0, blocky, 55, 45)
}
},
// 刷新
refresh () {
this.$emit('refresh')
},
sliderDown (event) {
this.originX = event.clientX
this.originY = event.clientY
this.isMouseDown = true
},
touchStartEvent (e) {
this.originX = e.changedTouches[0].pageX
this.originY = e.changedTouches[0].pageY
this.isMouseDown = true
},
bindEvents () {
document.addEventListener('mousemove', e => {
if (!this.isMouseDown) return false
const moveX = e.clientX - this.originX
const moveY = e.clientY - this.originY
if (moveX < 0 || moveX + 38 >= this.w) return false
this.sliderLeft = moveX + 'px'
const blockLeft = ((this.w - 40 - 20) / (this.w - 40)) * moveX
this.block.style.left = blockLeft + 'px'
this.containerActive = true // add active
this.sliderMaskWidth = moveX + 'px'
this.trail.push(moveY)
})
document.addEventListener('mouseup', e => {
if (!this.isMouseDown) return false
this.isMouseDown = false
if (e.clientX === this.originX) return false
this.containerActive = false // remove active
this.verify()
})
},
touchMoveEvent (e) {
if (!this.isMouseDown) return false
const moveX = e.changedTouches[0].pageX - this.originX
const moveY = e.changedTouches[0].pageY - this.originY
if (moveX < 0 || moveX + 38 >= this.w) return false
this.sliderLeft = moveX + 'px'
const blockLeft = ((this.w - 40 - 20) / (this.w - 40)) * moveX
this.block.style.left = blockLeft + 'px'
this.containerActive = true
this.sliderMaskWidth = moveX + 'px'
this.trail.push(moveY)
},
touchEndEvent (e) {
if (!this.isMouseDown) return false
this.isMouseDown = false
if (e.changedTouches[0].pageX === this.originX) return false
this.containerActive = false
this.verify()
},
verify () {
const arr = this.trail // drag y move distance
const average = arr.reduce(sum) / arr.length // average
const deviations = arr.map(x => x - average) // deviation array
const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) // standard deviation
const left = parseInt(this.block.style.left)
this.$emit('success', left, stddev)
},
reset (h) {
this.containerActive = false
this.containerSuccess = false
this.containerFail = false
this.sliderLeft = 0
this.block.style.left = 0
this.sliderMaskWidth = 0
this.canvasCtx.clearRect(0, 0, this.w, this.h)
this.blockCtx.clearRect(0, 0, this.w, this.h)
this.fail = false
this.showInfo = false
this.containerFail = false
this.containerSuccess = false
this.initImg(h)
},
handleFail () {
this.fail = true
this.showInfo = true
this.infoText = '验证失败'
this.containerFail = true
},
handleSuccess () {
this.showInfo = true
this.infoText = '验证成功'
this.containerSuccess = true
setTimeout(() => {
this.block.style.left = 0
this.sliderMaskWidth = 0
this.sliderLeft = 0
this.fail = false
this.showInfo = false
this.containerSuccess = false
}, 1000)
}
}
}
</script>
<style scoped>
.slide-verify {
position: relative;
width: 310px;
overflow: hidden;
}
.slide-verify-block {
position: absolute;
left: 0;
top: 0;
}
.slide-verify-refresh-icon {
position: absolute;
right: 0;
top: 0;
width: 34px;
height: 34px;
cursor: pointer;
content: '刷新';
font-size: 22px;
line-height: 34px;
text-align: center;
font-weight: bold;
color: #3fdeae;
/* background: url("../../assets/move/icon_light.png") 0 -437px; */
background-size: 34px 471px;
}
.slide-verify-refresh-icon:hover {
transform: rotate(180deg);
transition: all 0.2s ease-in-out;
}
.slide-verify-slider {
position: relative;
text-align: center;
width: 310px;
height: 40px;
line-height: 40px;
margin-top: 15px;
background: #f7f9fa;
color: #45494c;
border: 1px solid #e4e7eb;
}
.slide-verify-slider-mask {
position: absolute;
left: 0;
top: 0;
height: 40px;
border: 0 solid #1991fa;
background: #d1e9fe;
}
.slide-verify-info {
position: absolute;
top: 170px;
left: 0;
height: 30px;
width: 350px;
color: #fff;
text-align: center;
line-height: 30px;
background-color: #52ccba;
opacity: 0;
}
.slide-verify-info.fail {
background-color: #f57a7a;
}
.slide-verify-info.show {
animation: hide 1s ease;
}
@keyframes hide {
0% {opacity: 0;}
100% {opacity: 0.9;}
}
.slide-verify-slider-mask-item {
position: absolute;
top: 0;
left: 0;
width: 38px;
height: 38px;
background: #fff;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: background 0.2s linear;
}
.slide-verify-slider-mask-item:hover {
background: #1991fa;
}
.slide-verify-slider-mask-item:hover .slide-verify-slider-mask-item-icon {
background-position: 0 -13px;
}
.slide-verify-slider-mask-item-icon {
position: absolute;
top: 9px;
left: 7px;
width: 14px;
height: 12px;
content: '法币';
font-size: 22px;
color: #ddd;
/* text-align: center;
line-height: 12px; */
/* background: url("../../assets/move/icon_light.png") 0 -26px; */
/* background-size: 34px 471px; */
}
.container-active .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #1991fa;
}
.container-active .slide-verify-slider-mask {
height: 38px;
border-width: 1px;
}
.container-success .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #52ccba;
background-color: #52ccba !important;
}
.container-success .slide-verify-slider-mask {
height: 38px;
border: 1px solid #52ccba;
background-color: #d2f4ef;
}
.container-success .slide-verify-slider-mask-item-icon {
background-position: 0 0 !important;
}
.container-fail .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #f57a7a;
background-color: #f57a7a !important;
}
.container-fail .slide-verify-slider-mask {
height: 38px;
border: 1px solid #f57a7a;
background-color: #fce1e1;
}
.container-fail .slide-verify-slider-mask-item-icon {
top: 14px;
background-position: 0 -82px !important;
}
.container-active .slide-verify-slider-text,
.container-success .slide-verify-slider-text,
.container-fail .slide-verify-slider-text {
display: none;
}
</style>
效果图
缺点:如上图所示,缺口处没有加边框,显示不清楚。这个也很好理解。图片均由后端提供, 前端只是在canvas画布上重绘,事先并不知道图片缺口在哪。
方案二:
从引言中可知,这个图片滑动验证本质上不需要后端参与,前端自行随机截取滑块,然后移动就行。参照vue组件vue-monoplasty-slide-verify即可
代码如下
<template>
<div
class="slide-verify"
:style="{ width: w + 'px' }"
id="slideVerify"
onselectstart="return false;"
>
<!-- 图片加载遮蔽罩 -->
<div :class="{ 'slider-verify-loading': loadBlock }"></div>
<canvas :width="w" :height="h" ref="canvas"></canvas>
<div v-if="show" @click="refresh" class="slide-verify-refresh-icon"></div>
<canvas
:width="w"
:height="h"
ref="block"
class="slide-verify-block"
></canvas>
<!-- container -->
<div
class="slide-verify-slider"
:class="{
'container-active': containerActive,
'container-success': containerSuccess,
'container-fail': containerFail,
}"
>
<div class="slide-verify-slider-mask" :style="{ width: sliderMaskWidth }">
<!-- slider -->
<div
@mousedown="sliderDown"
@touchstart="touchStartEvent"
@touchmove="touchMoveEvent"
@touchend="touchEndEvent"
class="slide-verify-slider-mask-item"
:style="{ left: sliderLeft }"
>
<div class="slide-verify-slider-mask-item-icon"></div>
</div>
</div>
<span class="slide-verify-slider-text">{{ sliderText }}</span>
</div>
</div>
</template>
<script>
const PI = Math.PI
function sum (x, y) {
return x + y
}
function square (x) {
return x * x
}
export default {
name: 'SlideVerify',
props: {
// block length
l: {
type: Number,
default: 42
},
// block radius
r: {
type: Number,
default: 10
},
// canvas width
w: {
type: Number,
default: 310
},
// canvas height
h: {
type: Number,
default: 155
},
sliderText: {
type: String,
default: 'Slide filled right'
},
accuracy: {
type: Number,
default: 5 // 若为 -1 则不进行机器判断
},
show: {
type: Boolean,
default: true
},
imgs: {
type: Array,
default: () => []
}
},
data () {
return {
containerActive: false, // container active class
containerSuccess: false, // container success class
containerFail: false, // container fail class
canvasCtx: null,
blockCtx: null,
block: null,
block_x: undefined, // container random position
block_y: undefined,
L: this.l + this.r * 2 + 3, // block real lenght
img: undefined,
originX: undefined,
originY: undefined,
isMouseDown: false,
trail: [],
sliderLeft: 0, // block right offset
sliderMaskWidth: 0, // mask width,
success: false, // Bug Fixes 修复了验证成功后还能滑动
loadBlock: true, // Features 图片加载提示,防止图片没加载完就开始验证
timestamp: null
}
},
mounted () {
this.init()
},
methods: {
init () {
this.initDom()
this.initImg()
this.bindEvents()
},
initDom () {
this.block = this.$refs.block
this.canvasCtx = this.$refs.canvas.getContext('2d')
this.blockCtx = this.block.getContext('2d')
},
initImg () {
const img = this.createImg(() => {
// 图片加载完关闭遮蔽罩
this.loadBlock = false
this.drawBlock()
this.canvasCtx.drawImage(img, 0, 0, this.w, this.h)
this.blockCtx.drawImage(img, 0, 0, this.w, this.h)
let { block_x: x, block_y: y, r, L } = this
let _y = y - r * 2 - 1
let ImageData = this.blockCtx.getImageData(x, _y, L, L)
this.block.width = L
this.blockCtx.putImageData(ImageData, 0, _y)
})
this.img = img
},
drawBlock () {
this.block_x = this.getRandomNumberByRange(
this.L + 10,
this.w - (this.L + 10)
)
this.block_y = this.getRandomNumberByRange(
10 + this.r * 2,
this.h - (this.L + 10)
)
this.draw(this.canvasCtx, this.block_x, this.block_y, 'fill')
this.draw(this.blockCtx, this.block_x, this.block_y, 'clip')
},
draw (ctx, x, y, operation) {
let { l, r } = this
ctx.beginPath()
ctx.moveTo(x, y)
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
ctx.lineTo(x + l, y)
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
ctx.lineTo(x + l, y + l)
ctx.lineTo(x, y + l)
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
ctx.lineTo(x, y)
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
ctx.stroke()
ctx[operation]()
// Bug Fixes 修复了火狐和ie显示问题
ctx.globalCompositeOperation = 'destination-over'
},
createImg (onload) {
const img = document.createElement('img')
img.crossOrigin = 'Anonymous'
img.onload = onload
img.onerror = () => {
img.src = this.getRandomImg()
}
img.src = this.getRandomImg()
return img
},
// 随机生成img src
getRandomImg () {
// return require('../assets/img.jpg')
const len = this.imgs.length
return len > 0
? this.imgs[this.getRandomNumberByRange(0, len)]
: 'https://picsum.photos/300/150/?image=' +
this.getRandomNumberByRange(0, 1084)
},
getRandomNumberByRange (start, end) {
return Math.round(Math.random() * (end - start) + start)
},
refresh () {
this.reset()
this.$emit('refresh')
},
sliderDown (event) {
if (this.success) return
this.originX = event.clientX
this.originY = event.clientY
this.isMouseDown = true
this.timestamp = +new Date()
},
touchStartEvent (e) {
if (this.success) return
this.originX = e.changedTouches[0].pageX
this.originY = e.changedTouches[0].pageY
this.isMouseDown = true
this.timestamp = +new Date()
},
bindEvents () {
document.addEventListener('mousemove', (e) => {
if (!this.isMouseDown) return false
const moveX = e.clientX - this.originX
const moveY = e.clientY - this.originY
if (moveX < 0 || moveX + 38 >= this.w) return false
this.sliderLeft = moveX + 'px'
let blockLeft = ((this.w - 40 - 20) / (this.w - 40)) * moveX
this.block.style.left = blockLeft + 'px'
this.containerActive = true // add active
this.sliderMaskWidth = moveX + 'px'
this.trail.push(moveY)
})
document.addEventListener('mouseup', (e) => {
if (!this.isMouseDown) return false
this.isMouseDown = false
if (e.clientX === this.originX) return false
this.containerActive = false // remove active
this.timestamp = +new Date() - this.timestamp
const { spliced, TuringTest } = this.verify()
if (spliced) {
if (this.accuracy === -1) {
this.containerSuccess = true
this.success = true
this.$emit('success', this.timestamp)
return
}
if (TuringTest) {
// succ
this.containerSuccess = true
this.success = true
this.$emit('success', this.timestamp)
} else {
this.containerFail = true
this.$emit('again')
}
} else {
this.containerFail = true
this.$emit('fail')
setTimeout(() => {
this.reset()
}, 1000)
}
})
},
touchMoveEvent (e) {
if (!this.isMouseDown) return false
const moveX = e.changedTouches[0].pageX - this.originX
const moveY = e.changedTouches[0].pageY - this.originY
if (moveX < 0 || moveX + 38 >= this.w) return false
this.sliderLeft = moveX + 'px'
let blockLeft = ((this.w - 40 - 20) / (this.w - 40)) * moveX
this.block.style.left = blockLeft + 'px'
this.containerActive = true
this.sliderMaskWidth = moveX + 'px'
this.trail.push(moveY)
},
touchEndEvent (e) {
if (!this.isMouseDown) return false
this.isMouseDown = false
if (e.changedTouches[0].pageX === this.originX) return false
this.containerActive = false
this.timestamp = +new Date() - this.timestamp
const { spliced, TuringTest } = this.verify()
if (spliced) {
if (this.accuracy === -1) {
this.containerSuccess = true
this.success = true
this.$emit('success', this.timestamp)
return
}
if (TuringTest) {
// succ
this.containerSuccess = true
this.success = true
this.$emit('success', this.timestamp)
} else {
this.containerFail = true
this.$emit('again')
}
} else {
this.containerFail = true
this.$emit('fail')
setTimeout(() => {
this.reset()
}, 1000)
}
},
verify () {
const arr = this.trail // drag y move distance
const average = arr.reduce(sum) / arr.length // average
const deviations = arr.map((x) => x - average) // deviation array
const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) // standard deviation
const left = parseInt(this.block.style.left)
const accuracy =
this.accuracy <= 1 ? 1 : this.accuracy > 10 ? 10 : this.accuracy
return {
spliced: Math.abs(left - this.block_x) <= accuracy,
TuringTest: average !== stddev // equal => not person operate
}
},
reset () {
this.success = false
this.containerActive = false
this.containerSuccess = false
this.containerFail = false
this.sliderLeft = 0
this.block.style.left = 0
this.sliderMaskWidth = 0
// canvas
let { w, h } = this
this.canvasCtx.clearRect(0, 0, w, h)
this.blockCtx.clearRect(0, 0, w, h)
this.block.width = w
// generate img
this.img.src = this.getRandomImg()
this.$emit('fulfilled')
}
}
}
</script>
<style scoped>
.slide-verify {
position: relative;
}
/* 图片加载样式 */
.slider-verify-loading {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
z-index: 999;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
opacity: 0.7;
}
100% {
opacity: 9;
}
}
.slide-verify-block {
position: absolute;
left: 0;
top: 0;
}
.slide-verify-refresh-icon {
position: absolute;
right: 0;
top: 0;
width: 34px;
height: 34px;
cursor: pointer;
background: url("../assets/icon_light.png") 0 -437px;
background-size: 34px 471px;
}
.slide-verify-slider {
position: relative;
text-align: center;
width: 100%;
height: 40px;
line-height: 40px;
margin-top: 15px;
background: #f7f9fa;
color: #45494c;
border: 1px solid #e4e7eb;
}
.slide-verify-slider-mask {
position: absolute;
left: 0;
top: 0;
height: 40px;
border: 0 solid #1991fa;
background: #d1e9fe;
}
.slide-verify-slider-mask-item {
position: absolute;
top: 0;
left: 0;
width: 40px;
height: 40px;
background: #fff;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: background 0.2s linear;
}
.slide-verify-slider-mask-item:hover {
background: #1991fa;
}
.slide-verify-slider-mask-item:hover .slide-verify-slider-mask-item-icon {
background-position: 0 -13px;
}
.slide-verify-slider-mask-item-icon {
position: absolute;
top: 15px;
left: 13px;
width: 14px;
height: 12px;
background: url("../assets/icon_light.png") 0 -26px;
background-size: 34px 471px;
}
.container-active .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #1991fa;
}
.container-active .slide-verify-slider-mask {
height: 38px;
border-width: 1px;
}
.container-success .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #52ccba;
background-color: #52ccba !important;
}
.container-success .slide-verify-slider-mask {
height: 38px;
border: 1px solid #52ccba;
background-color: #d2f4ef;
}
.container-success .slide-verify-slider-mask-item-icon {
background-position: 0 0 !important;
}
.container-fail .slide-verify-slider-mask-item {
height: 38px;
top: -1px;
border: 1px solid #f57a7a;
background-color: #f57a7a !important;
}
.container-fail .slide-verify-slider-mask {
height: 38px;
border: 1px solid #f57a7a;
background-color: #fce1e1;
}
.container-fail .slide-verify-slider-mask-item-icon {
top: 14px;
background-position: 0 -82px !important;
}
.container-active .slide-verify-slider-text,
.container-success .slide-verify-slider-text,
.container-fail .slide-verify-slider-text {
display: none;
}
</style>
其中通过函数draw()中的
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'
给缺口绘制边框是关键,绘制结果如下