Support email verification

This commit is contained in:
Pig Fang 2018-08-17 12:32:44 +08:00
parent 706ca1938b
commit b5468cc143
41 changed files with 625 additions and 91 deletions

View File

@ -172,6 +172,7 @@ class AdminController extends Controller
});
$form->checkbox('user_can_register')->label();
$form->checkbox('require_verification')->label();
$form->text('regs_per_ip');
@ -241,7 +242,7 @@ class AdminController extends Controller
$isSingleUser = $request->has('uid');
if ($isSingleUser) {
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at'])
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at', 'verified'])
->where('uid', intval($request->input('uid')))
->get();
} else {
@ -251,7 +252,7 @@ class AdminController extends Controller
$page = $request->input('page', 1);
$perPage = $request->input('perPage', 10);
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at'])
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at', 'verified'])
->where('uid', 'like', '%' . $search . '%')
->orWhere('email', 'like', '%' . $search . '%')
->orWhere('nickname', 'like', '%' . $search . '%')
@ -341,6 +342,11 @@ class AdminController extends Controller
return json(trans('admin.users.operations.email.success'), 0);
} elseif ($action == "verification") {
$user->verified = !$user->verified;
$user->save();
return json(trans('admin.users.operations.verification.success'), 0);
} elseif ($action == "nickname") {
$this->validate($request, [
'nickname' => 'required|no_special_chars'

View File

@ -138,7 +138,7 @@ class AuthController extends Controller
if (config('mail.driver') != "") {
return view('auth.forgot');
} else {
throw new PrettyPageException(trans('auth.forgot.close'), 8);
throw new PrettyPageException(trans('auth.forgot.disabled'), 8);
}
}
@ -148,8 +148,9 @@ class AuthController extends Controller
'captcha' => 'required'.(app()->environment('testing') ? '' : '|captcha')
]);
if (config('mail.driver') == "")
return json(trans('auth.forgot.close'), 1);
if (! config('mail.driver')) {
return json(trans('auth.forgot.disabled'), 1);
}
if (Session::has('last_mail_time') && (time() - session('last_mail_time')) < 60)
return json(trans('auth.forgot.frequent-mail'), 1);
@ -191,9 +192,22 @@ class AuthController extends Controller
return json(trans('auth.reset.success'), 0);
}
protected function checkCaptcha($request)
public function verify(UserRepository $users, $uid)
{
return (strtolower($request->input('captcha')) == strtolower(session('phrase')));
if (! option('require_verification')) {
throw new PrettyPageException(trans('user.verification.disabled'), 1);
}
$user = $users->get($uid);
if (! $user || $user->verified) {
throw new PrettyPageException(trans('auth.verify.invalid'), 1);
}
$user->verified = true;
$user->save();
return view('auth.verify');
}
}

View File

@ -3,12 +3,16 @@
namespace App\Http\Controllers;
use App;
use URL;
use Mail;
use View;
use Utils;
use Session;
use Parsedown;
use App\Models\User;
use App\Models\Texture;
use Illuminate\Http\Request;
use App\Mail\EmailVerification;
use App\Events\UserProfileUpdated;
use Illuminate\Support\Facades\Auth;
use App\Exceptions\PrettyPageException;
@ -16,6 +20,17 @@ use App\Services\Repositories\UserRepository;
class UserController extends Controller
{
public function __construct()
{
$this->middleware(function ($request, $next) {
if (! Auth::user()->verified) {
$this->sendVerificationEmail();
}
return $next($request);
})->only(['index', 'profile']);
}
public function index()
{
$user = Auth::user();
@ -105,6 +120,40 @@ class UserController extends Controller
return $hours > 1 ? round($hours) : $hours;
}
public function sendVerificationEmail()
{
if (! option('require_verification')) {
return json(trans('user.verification.disabled'), 1);
}
// Rate limit of 60s
$remain = 60 + session('last_mail_time', 0) - time();
if ($remain > 0) {
return json(trans('user.verification.frequent-mail'));
}
$user = Auth::user();
if ($user->verified) {
return json(trans('user.verification.verified'), 1);
}
$url = URL::signedRoute('auth.verify', ['uid' => $user->uid]);
try {
Mail::to($user->email)->send(new EmailVerification($url));
} catch (\Exception $e) {
// Write the exception to log
report($e);
return json(trans('user.verification.failed', ['msg' => $e->getMessage()]), 2);
}
Session::put('last_mail_time', time());
return json(trans('user.verification.success'), 0);
}
public function profile()
{
return view('user.profile')->with('user', Auth::user());
@ -170,6 +219,10 @@ class UserController extends Controller
return json(trans('user.profile.email.wrong-password'), 1);
if ($user->setEmail($request->input('new_email'))) {
// Set account status to unverified
$user->verified = false;
$user->save();
event(new UserProfileUpdated($action, $user));
Auth::logout();

View File

@ -46,11 +46,12 @@ class Kernel extends HttpKernel
* @var array
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\CheckAuthenticated::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'admin' => \App\Http\Middleware\CheckAdministrator::class,
'player' => \App\Http\Middleware\CheckPlayerExist::class,
'setup' => \App\Http\Middleware\CheckInstallation::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'auth' => \App\Http\Middleware\CheckAuthenticated::class,
'verified' => \App\Http\Middleware\CheckUserVerified::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'admin' => \App\Http\Middleware\CheckAdministrator::class,
'player' => \App\Http\Middleware\CheckPlayerExist::class,
'setup' => \App\Http\Middleware\CheckInstallation::class,
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
];
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Http\Middleware;
class CheckUserVerified
{
public function handle($request, \Closure $next)
{
if (option('require_verification') && !auth()->user()->verified) {
abort(403, trans('auth.check.verified'));
}
return $next($request);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
class EmailVerification extends Mailable
{
use Queueable, SerializesModels;
public $url;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct($url)
{
$this->url = $url;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$site_name = option_localized('site_name');
return $this->from(config('mail.username'), $site_name)
->subject(trans('user.verification.mail.title', ['sitename' => $site_name]))
->view('mails.email-verification');
}
}

View File

@ -41,6 +41,7 @@ class User extends Authenticatable
'score' => 'integer',
'avatar' => 'integer',
'permission' => 'integer',
'verified' => 'bool',
];
/**

View File

@ -16,7 +16,7 @@ return [
|
*/
'driver' => menv('MAIL_DRIVER', 'smtp'),
'driver' => menv('MAIL_DRIVER'),
/*
|--------------------------------------------------------------------------

View File

@ -5,6 +5,7 @@ return [
'site_name' => 'Blessing Skin',
'site_description' => 'Open-source PHP Minecraft Skin Hosting Service',
'user_can_register' => 'true',
'require_verification' => 'false',
'regs_per_ip' => '3',
'ip_get_method' => '0',
'api_type' => 'false',

View File

@ -11,6 +11,7 @@ $factory->define(User::class, function (Faker\Generator $faker) {
'password' => app('cipher')->hash(str_random(10), config('secure.salt')),
'ip' => '127.0.0.1',
'permission' => 0,
'verified' => true,
'last_sign_at' => $faker->dateTime->format('d-M-Y H:i:s'),
'register_at' => $faker->dateTime->format('d-M-Y H:i:s')
];
@ -25,6 +26,7 @@ $factory->defineAs(User::class, 'admin', function (Faker\Generator $faker) {
'password' => app('cipher')->hash(str_random(10), config('secure.salt')),
'ip' => '127.0.0.1',
'permission' => 1,
'verified' => true,
'last_sign_at' => $faker->dateTime->format('d-M-Y H:i:s'),
'register_at' => $faker->dateTime->format('d-M-Y H:i:s')
];
@ -39,6 +41,7 @@ $factory->defineAs(User::class, 'superAdmin', function (Faker\Generator $faker)
'password' => app('cipher')->hash(str_random(10), config('secure.salt')),
'ip' => '127.0.0.1',
'permission' => 2,
'verified' => true,
'last_sign_at' => $faker->dateTime->format('d-M-Y H:i:s'),
'register_at' => $faker->dateTime->format('d-M-Y H:i:s')
];
@ -53,6 +56,7 @@ $factory->defineAs(User::class, 'banned', function (Faker\Generator $faker) {
'password' => app('cipher')->hash(str_random(10), config('secure.salt')),
'ip' => '127.0.0.1',
'permission' => -1,
'verified' => true,
'last_sign_at' => $faker->dateTime->format('d-M-Y H:i:s'),
'register_at' => $faker->dateTime->format('d-M-Y H:i:s')
];

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddVerificationToUsersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('verified')->default(false);
$table->string('verification_token')->default('');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
if (config('database.default') == 'sqlite') {
// Dropping columns from a SQLite database requires `doctrine/dbal` dependency.
// However, we won't install it because it's too hard to specify the version of
// all the new dependencies exactly to make them support PHP ^5.5.9. Damn it.
return;
}
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('verified');
$table->dropColumn('verification_token');
});
}
}

View File

@ -25,6 +25,10 @@
<span v-else-if="props.column.field === 'permission'">
{{ props.row | humanizePermission }}
</span>
<span v-else-if="props.column.field === 'verified'">
<span v-if="props.row.verified" v-t="'admin.verified'"></span>
<span v-else v-t="'admin.unverified'"></span>
</span>
<div v-else-if="props.column.field === 'operations'">
<div class="btn-group">
<button
@ -35,6 +39,7 @@
>{{ $t('general.more') }} <span class="caret"></span></button>
<ul class="dropdown-menu operations-menu">
<li><a @click="changeEmail(props.row)" v-t="'admin.changeEmail'" href="#"></a></li>
<li><a @click="toggleVerification(props.row)" v-t="'admin.toggleVerification'" href="#"></a></li>
<li><a @click="changeNickName(props.row)" v-t="'admin.changeNickName'" href="#"></a></li>
<li><a @click="changePassword(props.row)" v-t="'admin.changePassword'" href="#"></a></li>
<li><a @click="changeScore(props.row)" v-t="'admin.changeScore'" href="#"></a></li>
@ -110,6 +115,7 @@ export default {
{ field: 'score', label: this.$t('general.user.score'), type: 'number', width: '102px' },
{ field: 'players_count', label: this.$t('admin.playersCount'), type: 'number' },
{ field: 'permission', label: this.$t('admin.status'), globalSearchDisabled: true },
{ field: 'verified', label: this.$t('admin.verification'), type: 'boolean', globalSearchDisabled: true },
{ field: 'register_at', label: this.$t('general.user.register-at') },
{ field: 'operations', label: this.$t('admin.operationsTitle'), sortable: false, globalSearchDisabled: true }
],
@ -188,6 +194,14 @@ export default {
toastr.warning(msg);
}
},
async toggleVerification(user) {
const { msg } = await this.$http.post(
'/admin/users?action=verification',
{ uid: user.uid }
);
user.verified = !user.verified;
toastr.success(msg);
},
async changeNickName(user) {
const { dismiss, value } = await swal({
text: this.$t('admin.newUserNickname'),

View File

@ -1,5 +1,6 @@
<template>
<section class="content">
<email-verification />
<div class="row">
<!-- Left col -->
<div class="col-md-8">
@ -192,6 +193,7 @@
import toastr from 'toastr';
import Paginate from 'vuejs-paginate';
import ClosetItem from './ClosetItem';
import EmailVerification from './EmailVerification';
import { debounce } from '../../js/utils';
import { swal } from '../../js/notify';
@ -201,6 +203,7 @@ export default {
Paginate,
ClosetItem,
Previewer: () => import('../common/Previewer'),
EmailVerification,
},
data: () => ({
category: 'skin',

View File

@ -1,63 +1,67 @@
<template>
<div class="box">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.used.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="row">
<div class="col-md-8">
<div class="progress-group">
<span class="progress-text" v-t="'user.used.players'"></span>
<span class="progress-number"><b>{{ playersUsed }}</b> / {{ playersTotal }}</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" :style="{ width: playersPercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
<div class="progress-group">
<span class="progress-text" v-t="'user.used.storage'"></span>
<span class="progress-number" id="user-storage">
<template v-if="storageUsed > 1024">
<b>{{ round(storageUsed / 1024) }}</b> / {{ round(storageTotal / 1024) }} MB
</template>
<template v-else>
<b>{{ storageUsed }}</b> / {{ storageTotal }} KB
</template>
</span>
<div>
<email-verification />
<div class="box">
<div class="box-header with-border">
<h3 class="box-title" v-t="'user.used.title'"></h3>
</div><!-- /.box-header -->
<div class="box-body">
<div class="row">
<div class="col-md-8">
<div class="progress-group">
<span class="progress-text" v-t="'user.used.players'"></span>
<span class="progress-number"><b>{{ playersUsed }}</b> / {{ playersTotal }}</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" :style="{ width: playersPercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
<div class="progress-group">
<span class="progress-text" v-t="'user.used.storage'"></span>
<span class="progress-number" id="user-storage">
<template v-if="storageUsed > 1024">
<b>{{ round(storageUsed / 1024) }}</b> / {{ round(storageTotal / 1024) }} MB
</template>
<template v-else>
<b>{{ storageUsed }}</b> / {{ storageTotal }} KB
</template>
</span>
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" id="user-storage-bar" :style="{ width: storagePercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
</div><!-- /.col -->
<div class="col-md-4">
<p class="text-center">
<strong v-t="'user.cur-score'"></strong>
</p>
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
{{ score }}
</p>
<p class="text-center" style="font-size: smaller; margin-top: 20px;" v-t="'user.score-notice'"></p>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- ./box-body -->
<div class="box-footer">
<button v-if="canSign" class="btn btn-primary pull-left" @click="sign">
<i class="far fa-calendar-check" aria-hidden="true"></i> &nbsp;{{ $t('user.sign') }}
</button>
<button
v-else
class="btn btn-primary pull-left"
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
disabled
>
<i class="far fa-calendar-check" aria-hidden="true"></i> &nbsp;
{{ remainingTimeText }}
</button>
</div><!-- /.box-footer -->
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" id="user-storage-bar" :style="{ width: storagePercentage + '%' }"></div>
</div>
</div><!-- /.progress-group -->
</div><!-- /.col -->
<div class="col-md-4">
<p class="text-center">
<strong v-t="'user.cur-score'"></strong>
</p>
<p id="score" data-toggle="modal" data-target="#modal-score-instruction">
{{ score }}
</p>
<p class="text-center" style="font-size: smaller; margin-top: 20px;" v-t="'user.score-notice'"></p>
</div><!-- /.col -->
</div><!-- /.row -->
</div><!-- ./box-body -->
<div class="box-footer">
<button v-if="canSign" class="btn btn-primary pull-left" @click="sign">
<i class="far fa-calendar-check" aria-hidden="true"></i> &nbsp;{{ $t('user.sign') }}
</button>
<button
v-else
class="btn btn-primary pull-left"
:title="$t('user.last-sign', { time: lastSignAt.toLocaleString() })"
disabled
>
<i class="far fa-calendar-check" aria-hidden="true"></i> &nbsp;
{{ remainingTimeText }}
</button>
</div><!-- /.box-footer -->
</div>
</div>
</template>
<script>
import EmailVerification from './EmailVerification';
import { swal } from '../../js/notify';
import toastr from 'toastr';
@ -65,6 +69,9 @@ const ONE_DAY = 24 * 3600 * 1000;
export default {
name: 'Dashboard',
components: {
EmailVerification,
},
data: () => ({
score: 0,
lastSignAt: new Date(),

View File

@ -0,0 +1,36 @@
<template>
<div v-if="!verified" class="callout callout-info">
<h4><i class="fa fa-envelope"></i> {{ $t('user.verification.title') }}</h4>
<p>{{ $t('user.verification.message') }}
<span v-if="pending">
<i class="fa fa-spin fa-spinner"></i>
{{ $t('user.verification.sending') }}
</span>
<a v-else @click="resend" href="#">
{{ $t('user.verification.resend') }}
</a>
</p>
</div>
</template>
<script>
import { swal } from '../../js/notify';
export default {
name: 'EmailVerification',
data() {
return {
verified: !__bs_data__.unverified,
pending: false,
};
},
methods: {
async resend() {
this.pending = true;
const { errno, msg } = await this.$http.post('/user/email-verification');
swal({ type: errno === 0 ? 'success' : 'warning', text: msg });
this.pending = false;
}
}
};
</script>

View File

@ -1,5 +1,6 @@
<template>
<section class="content">
<email-verification />
<div class="row">
<div class="col-md-6">
<div v-once class="box box-primary">
@ -192,11 +193,15 @@
</template>
<script>
import EmailVerification from './EmailVerification';
import toastr from 'toastr';
import { swal } from '../../js/notify';
export default {
name: 'Profile',
components: {
EmailVerification,
},
data: () => ({
oldPassword: '',
newPassword: '',

View File

@ -326,6 +326,26 @@ test('change email', async () => {
expect(wrapper.text()).toContain('d@e.f');
});
test('toggle verification', async () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [
{ uid: 1, verified: false },
] });
Vue.prototype.$http.post.mockResolvedValue({ errno: 0, msg: '0' });
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(2) > a');
button.trigger('click');
await wrapper.vm.$nextTick();
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=verification',
{ uid: 1 }
);
await flushPromises();
expect(wrapper.text()).toContain('admin.verified');
});
test('change nickname', async () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [
{ uid: 1, nickname: 'old' },
@ -342,7 +362,7 @@ test('change nickname', async () => {
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(2) > a');
const button = wrapper.find('.operations-menu > li:nth-child(3) > a');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
@ -374,7 +394,7 @@ test('change password', async () => {
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(3) > a');
const button = wrapper.find('.operations-menu > li:nth-child(4) > a');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
@ -406,7 +426,7 @@ test('change score', async () => {
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(4) > a');
const button = wrapper.find('.operations-menu > li:nth-child(5) > a');
button.trigger('click');
expect(Vue.prototype.$http.post).not.toBeCalled();
@ -434,7 +454,7 @@ test('toggle admin', async () => {
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(6) > a');
const button = wrapper.find('.operations-menu > li:nth-child(7) > a');
button.trigger('click');
await wrapper.vm.$nextTick();
@ -463,7 +483,7 @@ test('toggle ban', async () => {
const wrapper = mount(Users);
await wrapper.vm.$nextTick();
const button = wrapper.find('.operations-menu > li:nth-child(7) > a');
const button = wrapper.find('.operations-menu > li:nth-child(8) > a');
button.trigger('click');
await wrapper.vm.$nextTick();

View File

@ -8,6 +8,8 @@ import { swal } from '@/js/notify';
jest.mock('@/js/notify');
window.__bs_data__ = { unverified: false };
test('fetch closet data before mount', () => {
Vue.prototype.$http.get.mockResolvedValue({});
mount(Closet);

View File

@ -6,6 +6,8 @@ import { swal } from '@/js/notify';
jest.mock('@/js/notify');
window.__bs_data__ = { unverified: false };
function scoreInfo(data = {}) {
return {
user: { score: 835, lastSignAt: '2018-08-07 16:06:49' },

View File

@ -0,0 +1,29 @@
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import EmailVerification from '@/components/user/EmailVerification';
import { swal } from '@/js/notify';
jest.mock('@/js/notify');
test('message box should not be render if verified', () => {
window.__bs_data__ = { unverified: false };
const wrapper = mount(EmailVerification);
expect(wrapper.isEmpty()).toBeTrue();
});
test('resend email', async () => {
window.__bs_data__ = { unverified: true };
Vue.prototype.$http.post
.mockResolvedValueOnce({ errno: 1, msg: '1' })
.mockResolvedValueOnce({ errno: 0, msg: '0' });
const wrapper = mount(EmailVerification);
const button = wrapper.find('a');
button.trigger('click');
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'warning', text: '1' });
button.trigger('click');
await wrapper.vm.$nextTick();
expect(swal).toBeCalledWith({ type: 'success', text: '0' });
});

View File

@ -7,6 +7,8 @@ import { swal } from '@/js/notify';
jest.mock('@/js/notify');
window.__bs_data__ = { unverified: false };
test('computed values', () => {
window.__bs_data__ = { admin: true };
const wrapper = mount(Profile);

View File

@ -25,6 +25,8 @@ users:
change: Edit Email
existed: :email is existed.
success: Email changed successfully.
verification:
success: Account verification status toggled successfully.
nickname:
change: Edit Nickname
success: Nickname changed successfully.

View File

@ -7,6 +7,7 @@ login:
check:
anonymous: Illegal access. Please log in first.
verified: To access this page, you should verify your email address first.
admin: Only admins are permitted to access this page.
banned: You are banned on this site. Please contact the admin.
@ -26,7 +27,7 @@ forgot:
button: Send
message: We will send you an E-mail to verify.
login-link: I do remember it
close: Password resetting is not available now.
disabled: Password resetting is not available.
frequent-mail: You click the send button too fast. Wait for 60 secs, guy.
unregistered: The email address is not registered.
success: Mail is sent. Will be expired in 1 hour, please check.
@ -56,6 +57,14 @@ bind:
introduction: Email addresses will be used for password resetting. We won't send you any spam.
registered: The email address is already registered.
verify:
title: Email Verification
success: Your account was now verified.
message: Welcome to :sitename!
button: Homepage
invalid: Invalid link.
expired: This link is expired, please resend a verification email.
validation:
identification: Invalid format of email or player name.
email: Email format is invalid.

View File

@ -208,6 +208,11 @@ user:
preference:
title: Preference
description: Default stands for the Steve model with 4-pixel width arms, and Slim for the Alex model with 3-pixel width arms.
verification:
title: Verify Your Account
message: You must verify your email address before using the skin hosting service. Haven't received the email?
resend: Click here to send again.
sending: Sending...
admin:
operationsTitle: Operations
@ -221,6 +226,8 @@ admin:
cannotDeleteAdmin: You can't delete admins.
cannotDeleteSuperAdmin: You can't delete super admin in this way
changeEmail: Edit Email
verification: Email Verification
toggleVerification: Toggle Verification Status
changeNickName: Edit Nickname
changePassword: Edit Password
changeScore: Edit Score
@ -235,6 +242,8 @@ admin:
normal: Normal
admin: Admin
superAdmin: Super Admin
unverified: Unverified
verified: Verified
pidNotice: >-
Please enter the tid of texture. Inputing 0 can clear texture of this
player.

View File

@ -75,6 +75,9 @@ general:
user_can_register:
title: Open Registration
label: Everyone is allowed to register.
require_verification:
title: Account Verification
label: Users must verify their email address first.
regs_per_ip: Max accounts of one IP
ip_get_method:
title: Get IP via

View File

@ -14,6 +14,18 @@ last-sign: Last signed at :time
sign-remain-time: Available after :time :unit
announcement: Announcement
verification:
disabled: Email verification is not available.
frequent-mail: You click the send button too fast. Wait for 60 secs, guy.
verified: Your account is already verified.
success: Verification link was sent, please check your inbox.
failed: We failed to send you the verification link. Detailed message :msg
mail:
title: Verify Your Account on :sitename
message: You are receiving this email because someone registered an account with this email address on :sitename.
reset: "Click here to verify your account: :url"
ignore: If you did not register an account, no further action is required.
score-intro:
title: What is score?
introduction: |

View File

@ -25,6 +25,8 @@ users:
change: 修改邮箱
existed: :email 已被占用
success: 邮箱修改成功
verification:
success: 用户的邮箱验证状态已修改
nickname:
change: 修改昵称
success: 昵称已成功设置为 :new

View File

@ -7,6 +7,7 @@ login:
check:
anonymous: 非法访问,请先登录
verified: 你必须验证邮箱后才能访问此页面
admin: 看起来你并不是管理员哦
banned: 你已经被本站封禁啦,请联系管理员解决
@ -26,7 +27,7 @@ forgot:
button: 发送
message: 我们将会向您发送一封验证邮件
login-link: 我又想起来了
close: 本站已关闭重置密码功能
disabled: 本站已关闭重置密码功能
frequent-mail: 你邮件发送得太频繁啦,过 60 秒后再点发送吧
unregistered: 该邮箱尚未注册
success: 邮件已发送,一小时内有效,请注意查收。
@ -56,6 +57,13 @@ bind:
introduction: 邮箱地址仅用于重置密码,我们将不会向您发送任何垃圾邮件
registered: 该邮箱已被占用
verify:
title: 邮箱验证
success: 邮箱验证成功
message: 欢迎使用 :sitename
button: 返回首页
invalid: 无效的链接
expired: 链接已失效,请重新发送验证邮件
validation:
identification: 邮箱或角色名格式错误

View File

@ -206,6 +206,11 @@ user:
preference:
title: 优先模型
description: Default 即为默认的 Steve 模型(手臂 4 个像素宽Slim 即为手臂宽 3 个像素的 Alex 模型。
verification:
title: 验证你的邮箱地址
message: 你必须验证你的邮箱才能正常使用本站的皮肤托管等功能。没有收到验证邮件?
resend: 点击这里再次发送。
sending: 正在发送……
admin:
operationsTitle: 更多操作
@ -219,6 +224,8 @@ admin:
cannotDeleteAdmin: 你不能删除管理员账号哦
cannotDeleteSuperAdmin: 超级管理员账号不能被这样删除的啦
changeEmail: 修改邮箱
verification: 邮箱验证
toggleVerification: 修改邮箱验证状态
changeNickName: 修改昵称
changePassword: 更改密码
changeScore: 更改积分
@ -233,6 +240,8 @@ admin:
normal: 普通用户
admin: 管理员
superAdmin: 超级管理员
unverified: 未验证
verified: 已验证
pidNotice: 输入要更换的材质的 TID输入 0 即可清除该角色的材质
changePlayerTexture: '更换角色 :player 的材质'
changeTexture: 更换材质

View File

@ -75,6 +75,9 @@ general:
user_can_register:
title: 开放注册
label: 任何人都可以注册
require_verification:
title: 邮箱验证
label: 用户必须验证邮箱后才能使用皮肤托管等功能
regs_per_ip: 每个 IP 限制注册数
ip_get_method:
title: IP 获取方式

View File

@ -14,6 +14,19 @@ last-sign: 上次签到于 :time
sign-remain-time: :time :unit 后可签到
announcement: 公告
verification:
disabled: 本站已关闭邮箱验证功能
frequent-mail: 你邮件发送得太频繁啦,过 60 秒后再点发送吧
verified: 你已经验证过邮箱了
success: 验证邮件已发送,请检查你的收件箱。
failed: 邮件发送失败,详细信息::msg
mail:
title: 验证您在 :sitename 上的账户邮箱
message: 您收到这封邮件,是因为有人在 :sitename 注册时使用了本邮箱地址。
reset: 点击此链接验证您的邮箱::url
ignore: 如果您并没有访问过我们的网站,或没有进行上述操作,请忽略这封邮件
score-intro:
title: 积分是个啥?
introduction: |

View File

@ -0,0 +1,32 @@
@extends('auth.master')
@section('title', trans('auth.verify.title'))
@section('content')
<div class="login-box">
<div class="login-logo">
<a href="{{ url('/') }}">{{ option_localized('site_name') }}</a>
</div>
<!-- /.login-logo -->
<div class="login-box-body">
<p class="login-box-msg">@lang('auth.verify.message', ['sitename' => option_localized('site_name')])</p>
<div class="callout callout-success">
<i class="icon fa fa-check"></i> @lang('auth.verify.success')
</div>
<div class="row">
<div class="col-xs-6 pull-right">
<a href="{{ url('/') }}" class="btn btn-primary btn-block btn-flat">
@lang('auth.verify.button')
</a>
</div><!-- /.col -->
</div>
</div>
<!-- /.login-box-body -->
</div>
<!-- /.login-box -->
@endsection

View File

@ -0,0 +1,3 @@
<p>{!! trans('user.verification.mail.message', ['sitename' => option_localized('site_name')]) !!}</p>
<p>{!! trans('user.verification.mail.reset', ['url' => $url]) !!}</p>
<p>{!! trans('user.verification.mail.ignore') !!}</p>

View File

@ -21,4 +21,12 @@
<section class="content"></section><!-- /.content -->
</div><!-- /.content-wrapper -->
<script>
Object.defineProperty(window, '__bs_data__', {
value: Object.freeze({
unverified: {{ option('require_verification') && !$user->verified ? 'true' : 'false' }}
}),
writable: false
})
</script>
@endsection

View File

@ -73,4 +73,12 @@
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<script>
Object.defineProperty(window, '__bs_data__', {
value: Object.freeze({
unverified: {{ option('require_verification') && !$user->verified ? 'true' : 'false' }}
}),
writable: false
})
</script>
@endsection

View File

@ -20,7 +20,8 @@
<script>
Object.defineProperty(window, '__bs_data__', {
value: Object.freeze({
admin: {{ (string) $user->isAdmin() ?: 'false' }}
admin: {{ (string) $user->isAdmin() ?: 'false' }},
unverified: {{ option('require_verification') && !$user->verified ? 'true' : 'false' }}
}),
writable: false
})

View File

@ -35,8 +35,9 @@ Route::group(['prefix' => 'auth'], function ()
Route::post('/login', 'AuthController@handleLogin');
Route::post('/register', 'AuthController@handleRegister');
Route::post('/forgot', 'AuthController@handleForgot');
Route::post('/reset/{uid}', 'AuthController@handleReset')->middleware('signed');
Route::get ('/verify/{uid}', 'AuthController@verify')->name('auth.verify')->middleware('signed');
});
/**
@ -53,16 +54,21 @@ Route::group(['middleware' => ['web', 'auth'], 'prefix' => 'user'], function ()
Route::post('/profile', 'UserController@handleProfile');
Route::post('/profile/avatar', 'UserController@setAvatar');
// Email Verification
Route::post('/email-verification', 'UserController@sendVerificationEmail');
// Player
Route::any ('/player', 'PlayerController@index');
Route::get ('/player/list', 'PlayerController@listAll');
Route::post('/player/add', 'PlayerController@add');
Route::any ('/player/show', 'PlayerController@show');
Route::post('/player/preference', 'PlayerController@setPreference');
Route::post('/player/set', 'PlayerController@setTexture');
Route::post('/player/texture/clear', 'PlayerController@clearTexture');
Route::post('/player/rename', 'PlayerController@rename');
Route::post('/player/delete', 'PlayerController@delete');
Route::group(['prefix' => 'player', 'middleware' => 'verified'], function () {
Route::any ('', 'PlayerController@index');
Route::get ('/list', 'PlayerController@listAll');
Route::post('/add', 'PlayerController@add');
Route::any ('/show', 'PlayerController@show');
Route::post('/preference', 'PlayerController@setPreference');
Route::post('/set', 'PlayerController@setTexture');
Route::post('/texture/clear', 'PlayerController@clearTexture');
Route::post('/rename', 'PlayerController@rename');
Route::post('/delete', 'PlayerController@delete');
});
// Closet
Route::get ('/closet', 'ClosetController@index');
@ -82,7 +88,7 @@ Route::group(['prefix' => 'skinlib'], function ()
Route::any('/show/{tid}', 'SkinlibController@show');
Route::any('/data', 'SkinlibController@getSkinlibFiltered');
Route::group(['middleware' => 'auth'], function ()
Route::group(['middleware' => ['auth', 'verified']], function ()
{
Route::get ('/upload', 'SkinlibController@upload');
Route::post('/upload', 'SkinlibController@handleUpload');

View File

@ -768,7 +768,9 @@ class AdminControllerTest extends BrowserKitTestCase
'nickname' => $user->nickname,
'score' => $user->score,
'avatar' => $user->avatar,
'permission' => $user->permission
'permission' => $user->permission,
'verified' => (bool) $user->verified,
'verification_token' => (string) $user->verification_token
]
]);

View File

@ -371,7 +371,7 @@ class AuthControllerTest extends TestCase
$this->get('/auth/forgot')->assertSee('Forgot Password');
config(['mail.driver' => '']);
$this->get('/auth/forgot')->assertSee(trans('auth.forgot.close'));
$this->get('/auth/forgot')->assertSee(trans('auth.forgot.disabled'));
}
public function testHandleForgot()
@ -384,7 +384,7 @@ class AuthControllerTest extends TestCase
'captcha' => 'a'
])->assertJson([
'errno' => 1,
'msg' => trans('auth.forgot.close')
'msg' => trans('auth.forgot.disabled')
]);
config(['mail.driver' => 'smtp']);
@ -501,6 +501,27 @@ class AuthControllerTest extends TestCase
$this->assertTrue($user->verifyPassword('12345678'));
}
public function testVerify()
{
$url = URL::signedRoute('auth.verify', ['uid' => 1]);
// Should be forbidden if account verification is disabled
option(['require_verification' => false]);
$this->get($url)->assertSee(trans('user.verification.disabled'));
option(['require_verification' => true]);
$this->get($url)->assertSee(trans('auth.verify.invalid'));
$user = factory(User::class)->create();
$url = URL::signedRoute('auth.verify', ['uid' => $user->uid]);
$this->get($url)->assertSee(trans('auth.verify.invalid'));
$user = factory(User::class)->create(['verified' => false]);
$url = URL::signedRoute('auth.verify', ['uid' => $user->uid]);
$this->get($url)->assertViewIs('auth.verify');
$this->assertEquals(1, User::find($user->uid)->verified);
}
public function testCaptcha()
{
$this->get('/auth/captcha')

View File

@ -48,6 +48,26 @@ class MiddlewareTest extends TestCase
$this->assertEquals('a@b.c', User::find($noEmailUser->uid)->email);
}
public function testCheckUserVerified()
{
$unverified = factory(User::class)->create(['verified' => false]);
option(['require_verification' => false]);
$this->actingAs($unverified)
->get('/skinlib/upload')
->assertSuccessful();
option(['require_verification' => true]);
$this->actingAs($unverified)
->get('/skinlib/upload')
->assertStatus(403)
->assertSee(trans('auth.check.verified'));
$this->actAs('normal')
->get('/skinlib/upload')
->assertSuccessful();
}
public function testCheckAdministrator()
{
// Without logged in

View File

@ -2,6 +2,8 @@
use App\Events;
use App\Models\User;
use App\Mail\EmailVerification;
use Illuminate\Support\Facades\Mail;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -21,6 +23,11 @@ class UserControllerTest extends TestCase
->assertViewHas('statistics')
->assertSee((new Parsedown())->text(option_localized('announcement')))
->assertSee((string) $user->score);
$unverified = factory(User::class)->create(['verified' => false]);
$this->actAs($unverified)
->get('/user')
->assertDontSee(trans('user.verification.notice.title'));
}
public function testScoreInfo()
@ -88,7 +95,6 @@ class UserControllerTest extends TestCase
// Can sign after 0 o'clock
option(['sign_after_zero' => true]);
$diff = \Carbon\Carbon::now()->diffInSeconds(\Carbon\Carbon::tomorrow());
$unit = '';
if ($diff / 3600 >= 1) {
$diff = round($diff / 3600);
$unit = 'hour';
@ -117,6 +123,66 @@ class UserControllerTest extends TestCase
]);
}
public function testSendVerificationEmail()
{
Mail::fake();
$unverified = factory(User::class)->create(['verified' => false]);
$verified = factory(User::class)->create();
// Should be forbidden if account verification is disabled
option(['require_verification' => false]);
$this->actingAs($unverified)
->postJson('/user/email-verification')
->assertJson([
'errno' => 1,
'msg' => trans('user.verification.disabled')
]);
option(['require_verification' => true]);
// Too frequent
$this->actingAs($unverified)
->withSession([
'last_mail_time' => time() - 10
])
->postJson('/user/email-verification')
->assertJson([
'errno' => 1,
'msg' => trans('user.verification.frequent-mail')
]);
$this->flushSession();
// Already verified
$this->actingAs($verified)
->postJson('/user/email-verification')
->assertJson([
'errno' => 1,
'msg' => trans('user.verification.verified')
]);
$this->actingAs($unverified)
->postJson('/user/email-verification')
->assertJson([
'errno' => 0,
'msg' => trans('user.verification.success')
]);
Mail::assertSent(EmailVerification::class, function ($mail) use ($unverified) {
return $mail->hasTo($unverified->email);
});
// Should handle exception when sending email
Mail::shouldReceive('to')
->once()
->andThrow(new \Mockery\Exception('A fake exception.'));
$this->flushSession();
$this->actingAs($unverified)
->postJson('/user/email-verification')
->assertJson([
'errno' => 2,
'msg' => trans('user.verification.failed', ['msg' => 'A fake exception.'])
]);
}
public function testProfile()
{
$this->actAs('normal')
@ -351,10 +417,13 @@ class UserControllerTest extends TestCase
'msg' => trans('user.profile.email.success')
]);
$this->assertEquals('a@b.c', User::find($user->uid)->email);
$this->assertEquals(0, User::find($user->uid)->verified);
// After changed email, user should re-login.
$this->assertGuest();
$user = User::find($user->uid);
$user->verified = true;
$user->save();
// Delete account without `password` field
$this->actAs($user)
->postJson(