Support reCAPTCHA

This commit is contained in:
Pig Fang 2019-03-24 09:58:37 +08:00
parent f6040707e1
commit 1fa155c213
23 changed files with 230 additions and 99 deletions

View File

@ -233,8 +233,14 @@ class AdminController extends Controller
$form->textarea('meta_extras')->rows(6);
})->handle();
$recaptcha = Option::form('recaptcha', 'reCAPTCHA', function ($form) {
$form->text('recaptcha_sitekey', 'sitekey');
$form->text('recaptcha_secretkey', 'secretkey');
$form->checkbox('recaptcha_mirror')->label();
})->handle();
return view('admin.options')
->with('forms', compact('general', 'announ', 'meta'));
->with('forms', compact('general', 'announ', 'meta', 'recaptcha'));
}
public function resource(Request $request)

View File

@ -10,6 +10,7 @@ use Session;
use App\Events;
use App\Models\User;
use App\Models\Player;
use App\Rules\Captcha;
use App\Mail\ForgotPassword;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -43,7 +44,7 @@ class AuthController extends Controller
$loginFails = (int) Cache::get($loginFailsCacheKey, 0);
if ($loginFails > 3) {
$this->validate($request, ['captcha' => 'required|captcha']);
$this->validate($request, ['captcha' => ['required', new Captcha]]);
}
if (! $user) {
@ -83,7 +84,12 @@ class AuthController extends Controller
public function register()
{
if (option('user_can_register')) {
return view('auth.register', ['extra' => ['player' => option('register_with_player_name')]]);
return view('auth.register', [
'extra' => [
'player' => option('register_with_player_name'),
'recaptcha' => option('recaptcha_sitekey'),
]
]);
} else {
throw new PrettyPageException(trans('auth.register.close'), 7);
}
@ -101,7 +107,7 @@ class AuthController extends Controller
$data = $this->validate($request, array_merge([
'email' => 'required|email|unique:users',
'password' => 'required|min:8|max:32',
'captcha' => 'required'.(app()->environment('testing') ? '' : '|captcha'),
'captcha' => ['required', new Captcha],
], $rule));
if (option('register_with_player_name')) {

43
app/Rules/Captcha.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class Captcha implements Rule
{
public function passes($attribute, $value)
{
if (app()->environment('testing')) {
return true;
}
$secretkey = option('recaptcha_secretkey');
if ($secretkey) {
$client = new \GuzzleHttp\Client();
$url = option('recaptcha_mirror')
? 'https://www.recaptcha.net/recaptcha/api/siteverify'
: 'https://www.google.com/recaptcha/api/siteverify';
$response = $client->post($url, [
'form_params' => [
'secret' => $secretkey,
'response' => $value,
]
]);
if ($response->getStatusCode() == 200) {
$body = json_decode((string) $response->getBody());
return $body->success;
}
return false;
}
return captcha_check($value);
}
public function message()
{
return option('recaptcha_secretkey')
? trans('validation.recaptcha')
: trans('validation.captcha');
}
}

View File

@ -50,4 +50,7 @@ return [
'meta_description' => '',
'meta_extras' => '',
'cdn_address' => '',
'recaptcha_sitekey' => '',
'recaptcha_secretkey' => '',
'recaptcha_mirror' => 'false',
];

View File

@ -28,6 +28,7 @@
"toastr": "^2.1.4",
"vue": "^2.6.9",
"vue-good-table": "^2.16.3",
"vue-recaptcha": "^1.1.1",
"vue-upload-component": "^2.8.19",
"vuejs-paginate": "^2.1.0"
},

View File

@ -0,0 +1,59 @@
<template>
<div v-if="recaptcha" class="row">
<div class="col-xs-12" style="padding-bottom: 5px">
<vue-recaptcha :sitekey="recaptcha" @verify="$emit('change', $event)" />
</div>
</div>
<div v-else class="row">
<div class="col-xs-8">
<div class="form-group has-feedback">
<input
ref="captcha"
type="text"
class="form-control"
:placeholder="$t('auth.captcha')"
@input="$emit('change', $event.target.value)"
>
</div>
</div>
<div class="col-xs-4">
<img
class="pull-right captcha"
:src="`${baseUrl}/auth/captcha?v=${time}`"
alt="CAPTCHA"
:title="$t('auth.change-captcha')"
data-placement="top"
data-toggle="tooltip"
@click="refreshCaptcha"
>
</div>
</div>
</template>
<script>
import VueRecaptcha from 'vue-recaptcha'
export default {
name: 'Captcha',
components: {
VueRecaptcha,
},
props: {
baseUrl: {
type: String,
default: blessing.base_url,
},
},
data() {
return {
time: Date.now(),
recaptcha: blessing.extra.recaptcha,
}
},
methods: {
refreshCaptcha() {
this.time = Date.now()
},
},
}
</script>

View File

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

View File

@ -21,30 +21,7 @@
<span class="glyphicon glyphicon-lock form-control-feedback" />
</div>
<div v-if="tooManyFails" class="row">
<div class="col-xs-8">
<div class="form-group has-feedback">
<input
ref="captcha"
v-model="captcha"
type="text"
class="form-control"
:placeholder="$t('auth.captcha')"
>
</div>
</div>
<div class="col-xs-4">
<img
class="pull-right captcha"
:src="`${baseUrl}/auth/captcha?v=${time}`"
alt="CAPTCHA"
:title="$t('auth.change-captcha')"
data-placement="top"
data-toggle="tooltip"
@click="refreshCaptcha"
>
</div>
</div>
<captcha v-if="tooManyFails" ref="captcha" @change="updateCaptcha" />
<div class="callout callout-info" :class="{ hide: !infoMsg }">{{ infoMsg }}</div>
<div class="callout callout-warning" :class="{ hide: !warningMsg }">{{ warningMsg }}</div>
@ -81,9 +58,17 @@
<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,
@ -96,7 +81,6 @@ export default {
password: '',
captcha: '',
remember: false,
time: Date.now(),
tooManyFails: blessing.extra.tooManyFails,
infoMsg: '',
warningMsg: '',
@ -121,12 +105,6 @@ export default {
return
}
if (this.tooManyFails && !captcha) {
this.infoMsg = this.$t('auth.emptyCaptcha')
this.$refs.captcha.focus()
return
}
this.pending = true
const {
errno, msg, login_fails: loginFails,
@ -149,15 +127,12 @@ export default {
swal({ type: 'error', text: this.$t('auth.tooManyFails') })
this.tooManyFails = true
}
this.refreshCaptcha()
this.infoMsg = ''
this.warningMsg = msg
this.pending = false
this.$refs.captcha.refreshCaptcha()
}
},
refreshCaptcha() {
this.time = Date.now()
},
},
}
</script>

View File

@ -64,30 +64,7 @@
<span class="glyphicon glyphicon-pencil form-control-feedback" />
</div>
<div class="row">
<div class="col-xs-8">
<div class="form-group has-feedback">
<input
ref="captcha"
v-model="captcha"
type="text"
class="form-control"
:placeholder="$t('auth.captcha')"
>
</div>
</div>
<div class="col-xs-4">
<img
class="pull-right captcha"
:src="`${baseUrl}/auth/captcha?v=${time}`"
alt="CAPTCHA"
:title="$t('auth.change-captcha')"
data-placement="top"
data-toggle="tooltip"
@click="refreshCaptcha"
>
</div>
</div>
<captcha ref="captcha" @change="updateCaptcha" />
<div class="callout callout-info" :class="{ hide: !infoMsg }">{{ infoMsg }}</div>
<div class="callout callout-warning" :class="{ hide: !warningMsg }">{{ warningMsg }}</div>
@ -114,9 +91,17 @@
<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,
@ -130,7 +115,6 @@ export default {
nickname: '',
playerName: '',
captcha: '',
time: Date.now(),
infoMsg: '',
warningMsg: '',
pending: false,
@ -184,12 +168,6 @@ export default {
return
}
if (!captcha) {
this.infoMsg = this.$t('auth.emptyCaptcha')
this.$refs.captcha.focus()
return
}
this.pending = true
const { errno, msg } = await this.$http.post(
'/auth/register',
@ -207,13 +185,10 @@ export default {
} else {
this.infoMsg = ''
this.warningMsg = msg
this.refreshCaptcha()
this.$refs.captcha.refreshCaptcha()
this.pending = false
}
},
refreshCaptcha() {
this.time = Date.now()
},
},
}
</script>

View File

@ -0,0 +1,23 @@
import { mount } from '@vue/test-utils'
import Captcha from '@/components/Captcha.vue'
test('display recaptcha', () => {
blessing.extra = { recaptcha: 'sitekey' }
const wrapper = mount(Captcha)
expect(wrapper.find('img').exists()).toBeFalse()
})
test('display characters captcha', () => {
blessing.extra = {}
const wrapper = mount(Captcha)
expect(wrapper.find('img').exists()).toBeTrue()
wrapper.find('input').setValue('abc')
expect(wrapper.emitted().change[0][0]).toBe('abc')
})
test('refresh captcha', () => {
jest.spyOn(Date, 'now')
const wrapper = mount(Captcha)
wrapper.find('img').trigger('click')
expect(Date.now).toBeCalledTimes(2)
})

View File

@ -0,0 +1,8 @@
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

@ -11,14 +11,6 @@ test('show captcha if too many login fails', () => {
expect(wrapper.find('img').attributes('src')).toMatch(/\/auth\/captcha\?v=\d+/)
})
test('click to refresh captcha', () => {
window.blessing.extra = { tooManyFails: true }
jest.spyOn(Date, 'now')
const wrapper = mount(Login)
wrapper.find('img').trigger('click')
expect(Date.now).toBeCalledTimes(2)
})
test('login', async () => {
window.blessing.extra = { tooManyFails: false }
Vue.prototype.$http.post
@ -55,9 +47,6 @@ test('login', async () => {
expect(swal).toBeCalledWith({ type: 'error', text: 'auth.tooManyFails' })
expect(wrapper.find('img').exists()).toBeTrue()
button.trigger('click')
expect(info.text()).toBe('auth.emptyCaptcha')
wrapper.find('[type="text"]').setValue('a')
wrapper.find('[type="checkbox"]').setChecked()
button.trigger('click')

View File

@ -7,13 +7,6 @@ jest.mock('@/js/notify')
window.blessing.extra = { player: false }
test('click to refresh captcha', () => {
jest.spyOn(Date, 'now')
const wrapper = mount(Register)
wrapper.find('img').trigger('click')
expect(Date.now).toBeCalledTimes(2)
})
test('require player name', () => {
window.blessing.extra = { player: true }
@ -74,10 +67,6 @@ test('register', async () => {
wrapper.findAll('[type="text"]').at(0)
.setValue('abc')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
expect(info.text()).toBe('auth.emptyCaptcha')
wrapper.findAll('[type="text"]').at(1)
.setValue('captcha')
button.trigger('click')

View File

@ -146,6 +146,11 @@ meta:
meta_extras:
title: Other Custom <meta> Tags
recaptcha:
recaptcha_mirror:
title: 3rd-party Mirror
label: Chinese users only.
res-warning: This page is ONLY for advanced users. If you aren't familiar with these, please don't modify them!
resources:

View File

@ -49,6 +49,7 @@ min:
not_in: 'The selected :attribute is invalid.'
numeric: 'The :attribute must be a number.'
present: 'The :attribute field must be present.'
recaptcha: 'reCAPTCHA validation failed.'
regex: 'The :attribute format is invalid.'
required: 'The :attribute field is required.'
required_if: 'The :attribute field is required when :other is :value.'

View File

@ -146,6 +146,11 @@ meta:
meta_extras:
title: 其它自定义 <meta> 标签
recaptcha:
recaptcha_mirror:
title: 第三方镜像
label: 国内用户请勾选此项
res-warning: 本页面仅供高级用户使用。如果您不清楚这些设置的含义,请不要随意修改它们!
resources:

View File

@ -52,6 +52,7 @@ min:
not_in: '已选的属性 :attribute 非法。'
numeric: ':attribute 必须是一个数字。'
present: ':attribute 必须存在。'
recaptcha: '未能通过 reCAPTCHA 的验证。'
regex: ':attribute 格式不正确。'
required: ':attribute 不能为空。'
required_if: '当 :other 为 :value 时 :attribute 不能为空。'

View File

@ -25,6 +25,8 @@
{!! $forms['announ']->render() !!}
{!! $forms['meta']->render() !!}
{!! $forms['recaptcha']->render() !!}
</div>
</div>

View File

@ -23,11 +23,14 @@
<!-- /.login-box-body -->
</div>
<!-- /.login-box -->
@include('common.recaptcha')
<script>
Object.defineProperty(blessing, 'extra', {
configurable: false,
get: () => Object.freeze(@json(['tooManyFails' => cache(sha1('login_fails_'.get_client_ip())) > 3]))
get: () => Object.freeze(@json([
'tooManyFails' => cache(sha1('login_fails_'.get_client_ip())) > 3,
'recaptcha' => option('recaptcha_sitekey'),
]))
})
</script>

View File

@ -17,7 +17,7 @@
<!-- /.form-box -->
</div>
<!-- /.register-box -->
@include('common.recaptcha')
<script>
Object.defineProperty(blessing, 'extra', {
get: () => Object.freeze(@json($extra)),

View File

@ -0,0 +1,11 @@
@if (option('recaptcha_sitekey'))
<script
src="https://www.{{ option('recaptcha_mirror')
? 'recaptcha.net'
: 'google.com'
}}/recaptcha/api.js?onload=vueRecaptchaApiLoaded&render=explicit"
async
defer
>
</script>
@endif

View File

@ -159,6 +159,15 @@ class AdminControllerTest extends BrowserKitTestCase
->see('<meta name="keywords" content="kw">')
->see('<meta name="description" content="desc">')
->see('<!-- nothing -->');
$this->visit('/admin/options')
->type('key', 'recaptcha_sitekey')
->type('secret', 'recaptcha_secretkey')
->check('recaptcha_mirror')
->press('submit_recaptcha');
$this->assertEquals('key', option('recaptcha_sitekey'));
$this->assertEquals('secret', option('recaptcha_secretkey'));
$this->assertTrue(option('recaptcha_mirror'));
}
public function testResource()

View File

@ -8557,6 +8557,11 @@ vue-loader@^15.7.0:
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-recaptcha@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/vue-recaptcha/-/vue-recaptcha-1.1.1.tgz#be38f4ffab500e7f4775f149a279bb962cf91f07"
integrity sha512-ThjujwmoLrDS6EeE7AGHmeGfWDhFOFUAyfPfSCXojYMoSwFOJv0LL0/86PqHwjHM69HIq0kBI9ps+JeIQa5V/A==
vue-style-loader@^4.1.0:
version "4.1.2"
resolved "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz#dedf349806f25ceb4e64f3ad7c0a44fba735fcf8"