Support reCAPTCHA
This commit is contained in:
parent
f6040707e1
commit
1fa155c213
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
43
app/Rules/Captcha.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -50,4 +50,7 @@ return [
|
|||
'meta_description' => '',
|
||||
'meta_extras' => '',
|
||||
'cdn_address' => '',
|
||||
'recaptcha_sitekey' => '',
|
||||
'recaptcha_secretkey' => '',
|
||||
'recaptcha_mirror' => 'false',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
59
resources/assets/src/components/Captcha.vue
Normal file
59
resources/assets/src/components/Captcha.vue
Normal 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>
|
||||
12
resources/assets/src/components/mixins/updateCaptcha.ts
Normal file
12
resources/assets/src/components/mixins/updateCaptcha.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import Vue from 'vue'
|
||||
|
||||
export default Vue.extend({
|
||||
data: () => ({
|
||||
captcha: '',
|
||||
}),
|
||||
methods: {
|
||||
updateCaptcha(value: string) {
|
||||
this.captcha = value
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
resources/assets/tests/components/Captcha.test.ts
Normal file
23
resources/assets/tests/components/Captcha.test.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -146,6 +146,11 @@ meta:
|
|||
meta_extras:
|
||||
title: 其它自定义 <meta> 标签
|
||||
|
||||
recaptcha:
|
||||
recaptcha_mirror:
|
||||
title: 第三方镜像
|
||||
label: 国内用户请勾选此项
|
||||
|
||||
res-warning: 本页面仅供高级用户使用。如果您不清楚这些设置的含义,请不要随意修改它们!
|
||||
|
||||
resources:
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ min:
|
|||
not_in: '已选的属性 :attribute 非法。'
|
||||
numeric: ':attribute 必须是一个数字。'
|
||||
present: ':attribute 必须存在。'
|
||||
recaptcha: '未能通过 reCAPTCHA 的验证。'
|
||||
regex: ':attribute 格式不正确。'
|
||||
required: ':attribute 不能为空。'
|
||||
required_if: '当 :other 为 :value 时 :attribute 不能为空。'
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
{!! $forms['announ']->render() !!}
|
||||
|
||||
{!! $forms['meta']->render() !!}
|
||||
|
||||
{!! $forms['recaptcha']->render() !!}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
<!-- /.form-box -->
|
||||
</div>
|
||||
<!-- /.register-box -->
|
||||
|
||||
@include('common.recaptcha')
|
||||
<script>
|
||||
Object.defineProperty(blessing, 'extra', {
|
||||
get: () => Object.freeze(@json($extra)),
|
||||
|
|
|
|||
11
resources/views/common/recaptcha.blade.php
Normal file
11
resources/views/common/recaptcha.blade.php
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user