Support invisible reCAPTCHA

This commit is contained in:
Pig Fang 2019-03-24 15:45:50 +08:00
parent d5903f6412
commit 04b8f73ac4
15 changed files with 124 additions and 77 deletions

View File

@ -236,6 +236,7 @@ class AdminController extends Controller
$recaptcha = Option::form('recaptcha', 'reCAPTCHA', function ($form) {
$form->text('recaptcha_sitekey', 'sitekey');
$form->text('recaptcha_secretkey', 'secretkey');
$form->checkbox('recaptcha_invisible')->label();
})->handle();
return view('admin.options')

View File

@ -88,6 +88,7 @@ class AuthController extends Controller
'extra' => [
'player' => option('register_with_player_name'),
'recaptcha' => option('recaptcha_sitekey'),
'invisible' => (bool) option('recaptcha_invisible'),
]
]);
} else {

View File

@ -52,4 +52,5 @@ return [
'cdn_address' => '',
'recaptcha_sitekey' => '',
'recaptcha_secretkey' => '',
'recaptcha_invisible' => 'false',
];

View File

@ -3,8 +3,9 @@
<div class="col-xs-12" style="padding-bottom: 5px">
<vue-recaptcha
ref="recaptcha"
:size="invisible ? 'invisible' : ''"
:sitekey="recaptcha"
@verify="$emit('change', $event)"
@verify="onVerify"
/>
</div>
</div>
@ -13,10 +14,10 @@
<div class="form-group has-feedback">
<input
ref="captcha"
v-model="value"
type="text"
class="form-control"
:placeholder="$t('auth.captcha')"
@input="$emit('change', $event.target.value)"
>
</div>
</div>
@ -50,11 +51,26 @@ export default {
},
data() {
return {
value: '',
time: Date.now(),
recaptcha: blessing.extra.recaptcha,
invisible: blessing.extra.invisible,
}
},
methods: {
execute() {
return new Promise(resolve => {
if (this.invisible) {
this.$refs.recaptcha.$once('verify', resolve)
this.$refs.recaptcha.execute()
} else {
resolve(this.value)
}
})
},
onVerify(response) {
this.value = response
},
refreshCaptcha() {
if (this.recaptcha) {
this.$refs.recaptcha.reset()

View File

@ -1,12 +0,0 @@
import Vue from 'vue'
export default Vue.extend({
data: () => ({
captcha: '',
}),
methods: {
updateCaptcha(value: string) {
this.captcha = value
},
},
})

View File

@ -1,5 +1,5 @@
<template>
<form>
<form @submit.prevent="login">
<div class="form-group has-feedback">
<input
ref="identification"
@ -21,7 +21,7 @@
<span class="glyphicon glyphicon-lock form-control-feedback" />
</div>
<captcha v-if="tooManyFails" ref="captcha" @change="updateCaptcha" />
<captcha v-if="tooManyFails" ref="captcha" />
<div class="callout callout-info" :class="{ hide: !infoMsg }">{{ infoMsg }}</div>
<div class="callout callout-warning" :class="{ hide: !warningMsg }">{{ warningMsg }}</div>
@ -47,7 +47,7 @@
<button
v-else
class="btn btn-primary btn-block btn-flat"
@click.prevent="login"
type="submit"
>
{{ $t('auth.login') }}
</button>
@ -59,16 +59,12 @@
<script>
import { swal } from '../../js/notify'
import Captcha from '../../components/Captcha.vue'
import updateCaptcha from '../../components/mixins/updateCaptcha'
export default {
name: 'Login',
components: {
Captcha,
},
mixins: [
updateCaptcha,
],
props: {
baseUrl: {
type: String,
@ -79,7 +75,6 @@ export default {
return {
identification: '',
password: '',
captcha: '',
remember: false,
tooManyFails: blessing.extra.tooManyFails,
infoMsg: '',
@ -90,7 +85,7 @@ export default {
methods: {
async login() {
const {
identification, password, captcha, remember,
identification, password, remember,
} = this
if (!identification) {
@ -114,7 +109,9 @@ export default {
identification,
password,
keep: remember,
captcha: this.tooManyFails ? captcha : undefined,
captcha: this.tooManyFails
? await this.$refs.captcha.execute()
: void 0,
}
)
if (errno === 0) {

View File

@ -1,5 +1,5 @@
<template>
<form>
<form @submit.prevent="submit">
<div class="form-group has-feedback">
<input
ref="email"
@ -80,7 +80,7 @@
<button
v-else
class="btn btn-primary btn-block btn-flat"
@click.prevent="submit"
type="submit"
>
{{ $t('auth.register-button') }}
</button>
@ -92,16 +92,12 @@
<script>
import { swal } from '../../js/notify'
import Captcha from '../../components/Captcha.vue'
import updateCaptcha from '../../components/mixins/updateCaptcha'
export default {
name: 'Register',
components: {
Captcha,
},
mixins: [
updateCaptcha,
],
props: {
baseUrl: {
type: String,
@ -114,7 +110,6 @@ export default {
confirm: '',
nickname: '',
playerName: '',
captcha: '',
infoMsg: '',
warningMsg: '',
pending: false,
@ -174,7 +169,7 @@ export default {
Object.assign({
email,
password,
captcha,
captcha: await this.$refs.captcha.execute(),
}, this.requirePlayer ? { player_name: playerName } : { nickname })
)
if (errno === 0) {

View File

@ -2,6 +2,14 @@ import Vue from 'vue'
import { mount } from '@vue/test-utils'
import Captcha from '@/components/Captcha.vue'
const VueRecaptcha = Vue.extend({
methods: {
execute() {
this.$emit('verify', 'value')
},
},
})
test('display recaptcha', () => {
blessing.extra = { recaptcha: 'sitekey' }
const wrapper = mount(Captcha)
@ -13,12 +21,28 @@ test('refresh recaptcha', () => {
wrapper.vm.refreshCaptcha()
})
test('display characters captcha', () => {
test('recaptcha verified', () => {
const wrapper =
mount<Vue & { onVerify(response: string): void, value: string }>(Captcha)
wrapper.vm.onVerify('value')
expect(wrapper.vm.value).toBe('value')
})
test('invoke recaptcha', async () => {
const wrapper = mount<Vue & { execute(): Promise<string> }>(Captcha, { stubs: { VueRecaptcha } })
wrapper.setData({ invisible: true })
expect(await wrapper.vm.execute()).toBe('value')
wrapper.setData({ invisible: false, value: 'haha' })
expect(await wrapper.vm.execute()).toBe('haha')
})
test('display characters captcha', async () => {
blessing.extra = {}
const wrapper = mount(Captcha)
const wrapper = mount<Vue & { execute(): Promise<string> }>(Captcha)
expect(wrapper.find('img').exists()).toBeTrue()
wrapper.find('input').setValue('abc')
expect(wrapper.emitted().change[0][0]).toBe('abc')
expect(await wrapper.vm.execute()).toBe('abc')
})
test('refresh captcha', () => {

View File

@ -1,8 +0,0 @@
import { mount } from '@vue/test-utils'
import updateCaptcha from '@/components/mixins/updateCaptcha'
test('update captcha', () => {
const wrapper = mount(updateCaptcha)
wrapper.vm.updateCaptcha('value')
expect(wrapper.vm.captcha).toBe('value')
})

View File

@ -5,6 +5,16 @@ import { swal } from '@/js/notify'
jest.mock('@/js/notify')
const Captcha = Vue.extend({
methods: {
execute() {
return Promise.resolve('a')
},
refreshCaptcha() { /* */ },
},
template: '<img>',
})
test('show captcha if too many login fails', () => {
window.blessing.extra = { tooManyFails: true }
const wrapper = mount(Login)
@ -17,22 +27,22 @@ test('login', async () => {
.mockResolvedValueOnce({ errno: 1, msg: 'fail' })
.mockResolvedValueOnce({ errno: 1, login_fails: 4 })
.mockResolvedValueOnce({ errno: 0, msg: 'ok' })
const wrapper = mount(Login)
const button = wrapper.find('button')
const wrapper = mount(Login, { stubs: { Captcha } })
const form = wrapper.find('form')
const info = wrapper.find('.callout-info')
const warning = wrapper.find('.callout-warning')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyIdentification')
wrapper.find('[type="email"]').setValue('a@b.c')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyPassword')
wrapper.find('[type="password"]').setValue('123')
button.trigger('click')
form.trigger('submit')
await wrapper.vm.$nextTick()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/login',
@ -42,14 +52,14 @@ test('login', async () => {
)
expect(warning.text()).toBe('fail')
button.trigger('click')
form.trigger('submit')
await wrapper.vm.$nextTick()
expect(swal).toBeCalledWith({ type: 'error', text: 'auth.tooManyFails' })
expect(wrapper.find('img').exists()).toBeTrue()
wrapper.find('[type="text"]').setValue('a')
wrapper.find('[type="checkbox"]').setChecked()
button.trigger('click')
form.trigger('submit')
await wrapper.vm.$nextTick()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/login',
{

View File

@ -2,11 +2,21 @@ import Vue from 'vue'
import { mount } from '@vue/test-utils'
import Register from '@/views/auth/Register.vue'
import { swal } from '@/js/notify'
import { flushPromises } from '../../utils'
jest.mock('@/js/notify')
window.blessing.extra = { player: false }
const Captcha = Vue.extend({
methods: {
execute() {
return Promise.resolve('captcha')
},
refreshCaptcha() { /* */ },
},
})
test('require player name', () => {
window.blessing.extra = { player: true }
@ -22,54 +32,52 @@ test('register', async () => {
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: 'fail' })
.mockResolvedValueOnce({ errno: 0, msg: 'ok' })
const wrapper = mount(Register)
const button = wrapper.find('button')
const wrapper = mount(Register, { stubs: { Captcha } })
const form = wrapper.find('form')
const info = wrapper.find('.callout-info')
const warning = wrapper.find('.callout-warning')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyEmail')
wrapper.find('[type="email"]').setValue('a')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.invalidEmail')
wrapper.find('[type="email"]').setValue('a@b.c')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyPassword')
wrapper.findAll('[type="password"]').at(0)
.setValue('123456')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.invalidPassword')
wrapper.findAll('[type="password"]').at(0)
.setValue('12345678')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.invalidConfirmPwd')
wrapper.findAll('[type="password"]').at(1)
.setValue('123456')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.invalidConfirmPwd')
wrapper.findAll('[type="password"]').at(1)
.setValue('12345678')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyNickname')
wrapper.findAll('[type="text"]').at(0)
.setValue('abc')
wrapper.findAll('[type="text"]').at(1)
.setValue('captcha')
button.trigger('click')
form.trigger('submit')
await wrapper.vm.$nextTick()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/register',
@ -80,11 +88,11 @@ test('register', async () => {
captcha: 'captcha',
}
)
expect(warning.text()).toBe('fail')
expect(Date.now).toBeCalledTimes(2)
button.trigger('click')
await wrapper.vm.$nextTick()
expect(warning.text()).toBe('fail')
form.trigger('submit')
await flushPromises()
jest.runAllTimers()
expect(swal).toBeCalledWith({ type: 'success', text: 'ok' })
})
@ -92,24 +100,22 @@ test('register', async () => {
test('register with player name', async () => {
window.blessing.extra = { player: true }
Vue.prototype.$http.post.mockResolvedValue({ errno: 0, msg: 'ok' })
const wrapper = mount(Register)
const button = wrapper.find('button')
const wrapper = mount(Register, { stubs: { Captcha } })
const form = wrapper.find('form')
const info = wrapper.find('.callout-info')
wrapper.find('[type="email"]').setValue('a@b.c')
wrapper.findAll('[type="password"]').at(0)
.setValue('12345678')
wrapper.findAll('[type="password"]').at(1)
.setValue('12345678')
wrapper.findAll('[type="text"]').at(1)
.setValue('captcha')
button.trigger('click')
form.trigger('submit')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyPlayerName')
wrapper.findAll('[type="text"]').at(0)
.setValue('abc')
button.trigger('click')
form.trigger('submit')
await wrapper.vm.$nextTick()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/register',

View File

@ -146,6 +146,11 @@ meta:
meta_extras:
title: Other Custom <meta> Tags
recaptcha:
recaptcha_invisible:
title: Invisible
label: Enable Invisible Mode
res-warning: This page is ONLY for advanced users. If you aren't familiar with these, please don't modify them!
resources:

View File

@ -146,6 +146,11 @@ meta:
meta_extras:
title: 其它自定义 <meta> 标签
recaptcha:
recaptcha_invisible:
title: 隐藏
label: 开启隐藏式人机验证模式
res-warning: 本页面仅供高级用户使用。如果您不清楚这些设置的含义,请不要随意修改它们!
resources:

View File

@ -24,13 +24,17 @@
</div>
<!-- /.login-box -->
@include('common.recaptcha')
@php
$extra = [
'tooManyFails' => cache(sha1('login_fails_'.get_client_ip())) > 3,
'recaptcha' => option('recaptcha_sitekey'),
'invisible' => (bool) option('recaptcha_invisible'),
];
@endphp
<script>
Object.defineProperty(blessing, 'extra', {
configurable: false,
get: () => Object.freeze(@json([
'tooManyFails' => cache(sha1('login_fails_'.get_client_ip())) > 3,
'recaptcha' => option('recaptcha_sitekey'),
]))
get: () => Object.freeze(@json($extra))
})
</script>

View File

@ -163,9 +163,11 @@ class AdminControllerTest extends BrowserKitTestCase
$this->visit('/admin/options')
->type('key', 'recaptcha_sitekey')
->type('secret', 'recaptcha_secretkey')
->check('recaptcha_invisible')
->press('submit_recaptcha');
$this->assertEquals('key', option('recaptcha_sitekey'));
$this->assertEquals('secret', option('recaptcha_secretkey'));
$this->assertTrue(option('recaptcha_invisible'));
}
public function testResource()