Add more events for authentication

This commit is contained in:
Pig Fang 2019-12-24 17:09:30 +08:00
parent 0195b0fbd0
commit 2b827cf651
3 changed files with 273 additions and 93 deletions

View File

@ -11,6 +11,7 @@ use App\Rules;
use Auth;
use Cache;
use Carbon\Carbon;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use Mail;
@ -31,25 +32,29 @@ class AuthController extends Controller
]);
}
public function handleLogin(Request $request, Rules\Captcha $captcha)
{
public function handleLogin(
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher
) {
$this->validate($request, [
'identification' => 'required',
'password' => 'required|min:6|max:32',
]);
$identification = $request->input('identification');
$password = $request->input('password');
// Guess type of identification
$authType = filter_var($identification, FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
$dispatcher->dispatch('auth.login.attempt', [$identification, $password, $authType]);
event(new Events\UserTryToLogin($identification, $authType));
if ($authType == 'email') {
$user = User::where('email', $identification)->first();
} else {
$player = Player::where('name', $identification)->first();
$user = $player ? $player->user : null;
$user = optional($player)->user;
}
// Require CAPTCHA if user fails to login more than 3 times
@ -62,39 +67,42 @@ class AuthController extends Controller
if (!$user) {
return json(trans('auth.validation.user'), 2);
}
$dispatcher->dispatch('auth.login.ready', [$user]);
if ($user->verifyPassword($request->input('password'))) {
Session::forget('login_fails');
Cache::forget($loginFailsCacheKey);
Auth::login($user, $request->input('keep'));
$dispatcher->dispatch('auth.login.succeeded', [$user]);
event(new Events\UserLoggedIn($user));
return json(trans('auth.login.success'), 0, [
'redirectTo' => $request->session()->pull('last_requested_path', url('/user')),
]);
} else {
if ($user->verifyPassword($request->input('password'))) {
Session::forget('login_fails');
$loginFails++;
Cache::put($loginFailsCacheKey, $loginFails, 3600);
$dispatcher->dispatch('auth.login.failed', [$user, $loginFails]);
Auth::login($user, $request->input('keep'));
event(new Events\UserLoggedIn($user));
Cache::forget($loginFailsCacheKey);
return json(trans('auth.login.success'), 0, [
'redirectTo' => $request->session()->pull('last_requested_path', url('/user')),
]);
} else {
// Increase the counter
Cache::put($loginFailsCacheKey, ++$loginFails, 3600);
return json(trans('auth.validation.password'), 1, [
'login_fails' => $loginFails,
]);
}
return json(trans('auth.validation.password'), 1, [
'login_fails' => $loginFails,
]);
}
}
public function logout()
public function logout(Dispatcher $dispatcher)
{
if (Auth::check()) {
Auth::logout();
$user = Auth::user();
return json(trans('auth.logout.success'), 0);
} else {
return json(trans('auth.logout.fail'), 1);
}
$dispatcher->dispatch('auth.logout.before', [$user]);
Auth::logout();
$dispatcher->dispatch('auth.logout.after', [$user]);
return json(trans('auth.logout.success'), 0);
}
public function register()
@ -113,8 +121,11 @@ class AuthController extends Controller
}
}
public function handleRegister(Request $request, Rules\Captcha $captcha)
{
public function handleRegister(
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher
) {
if (!option('user_can_register')) {
return json(trans('auth.register.close'), 7);
}
@ -133,6 +144,8 @@ class AuthController extends Controller
'captcha' => ['required', $captcha],
], $rule));
$dispatcher->dispatch('auth.registration.attempt', [$data]);
if (option('register_with_player_name')) {
event(new Events\CheckPlayerExists($request->get('player_name')));
@ -147,6 +160,8 @@ class AuthController extends Controller
return json(trans('auth.register.max', ['regs' => option('regs_per_ip')]), 7);
}
$dispatcher->dispatch('auth.registration.ready', [$data]);
$user = new User();
$user->email = $data['email'];
$user->nickname = $data[option('register_with_player_name') ? 'player_name' : 'nickname'];
@ -161,6 +176,7 @@ class AuthController extends Controller
$user->save();
$dispatcher->dispatch('auth.registration.completed', [$user]);
event(new Events\UserRegistered($user));
if (option('register_with_player_name')) {
@ -173,7 +189,9 @@ class AuthController extends Controller
event(new Events\PlayerWasAdded($player));
}
$dispatcher->dispatch('auth.login.ready', [$user]);
Auth::login($user);
$dispatcher->dispatch('auth.login.succeeded', [$user]);
return json(trans('auth.register.success'), 0);
}
@ -192,9 +210,13 @@ class AuthController extends Controller
}
}
public function handleForgot(Request $request, Rules\Captcha $captcha)
{
$this->validate($request, [
public function handleForgot(
Request $request,
Rules\Captcha $captcha,
Dispatcher $dispatcher
) {
$data = $this->validate($request, [
'email' => 'required|email',
'captcha' => ['required', $captcha],
]);
@ -202,31 +224,35 @@ class AuthController extends Controller
return json(trans('auth.forgot.disabled'), 1);
}
$email = $data['email'];
$dispatcher->dispatch('auth.forgot.attempt', [$email]);
$rateLimit = 180;
$lastMailCacheKey = sha1('last_mail_'.get_client_ip());
$remain = $rateLimit + Cache::get($lastMailCacheKey, 0) - time();
// Rate limit
if ($remain > 0) {
return json(trans('auth.forgot.frequent-mail'), 2);
}
$user = User::where('email', $request->email)->first();
$user = User::where('email', $email)->first();
if (!$user) {
return json(trans('auth.forgot.unregistered'), 1);
}
$url = URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid]);
$dispatcher->dispatch('auth.forgot.ready', [$user]);
$url = URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid]);
try {
Mail::to($request->input('email'))->send(new ForgotPassword($url));
Mail::to($email)->send(new ForgotPassword($url));
} catch (\Exception $e) {
report($e);
$dispatcher->dispatch('auth.forgot.failed', [$user, $url]);
return json(trans('auth.forgot.failed', ['msg' => $e->getMessage()]), 2);
}
$dispatcher->dispatch('auth.forgot.sent', [$user, $url]);
Cache::put($lastMailCacheKey, time(), 3600);
return json(trans('auth.forgot.success'), 0);
@ -237,13 +263,16 @@ class AuthController extends Controller
return view('auth.reset')->with('user', User::find($uid));
}
public function handleReset(Request $request, $uid)
public function handleReset(Dispatcher $dispatcher, Request $request, $uid)
{
$validated = $this->validate($request, [
['password' => $password] = $this->validate($request, [
'password' => 'required|min:8|max:32',
]);
$user = User::find($uid);
User::find($uid)->changePassword($validated['password']);
$dispatcher->dispatch('auth.reset.before', [$user, $password]);
$user->changePassword($password);
$dispatcher->dispatch('auth.reset.after', [$user, $password]);
return json(trans('auth.reset.success'), 0);
}
@ -314,7 +343,7 @@ class AuthController extends Controller
return Socialite::driver($driver)->redirect();
}
public function oauthCallback($driver)
public function oauthCallback(Dispatcher $dispatcher, $driver)
{
$remoteUser = Socialite::driver($driver)->user();
@ -324,11 +353,7 @@ class AuthController extends Controller
}
$user = User::where('email', $email)->first();
if ($user) {
event(new Events\UserLoggedIn($user));
Auth::login($user);
} else {
if (!$user) {
$user = new User();
$user->email = $email;
$user->nickname = $remoteUser->nickname ?? $remoteUser->name ?? $email;
@ -342,11 +367,13 @@ class AuthController extends Controller
$user->verified = true;
$user->save();
event(new Events\UserRegistered($user));
Auth::login($user);
$dispatcher->dispatch('auth.registration.completed', [$user]);
}
$dispatcher->dispatch('auth.login.ready', [$user]);
Auth::login($user);
$dispatcher->dispatch('auth.login.succeeded', [$user]);
return redirect('/user');
}
}

View File

@ -32,7 +32,7 @@ Route::group(['prefix' => 'auth'], function () {
Route::get('/login/{driver}/callback', 'AuthController@oauthCallback');
});
Route::any('/logout', 'AuthController@logout');
Route::post('/logout', 'AuthController@logout')->middleware('authorize');
Route::any('/captcha', 'AuthController@captcha');
Route::post('/login', 'AuthController@handleLogin');

View File

@ -75,6 +75,28 @@ class AuthControllerTest extends TestCase
$this->flushSession();
// Should return a warning if user isn't existed
$this->postJson(
'/auth/login', [
'identification' => 'nope@nope.net',
'password' => '12345678',
])->assertJson([
'code' => 2,
'message' => trans('auth.validation.user'),
]);
Event::assertDispatched('auth.login.attempt', function ($event, $payload) use ($user) {
$this->assertEquals('nope@nope.net', $payload[0]);
$this->assertEquals('12345678', $payload[1]);
$this->assertEquals('email', $payload[2]);
return true;
});
Event::assertNotDispatched('auth.login.ready');
Event::assertNotDispatched('auth.login.succeeded');
Event::assertNotDispatched('auth.login.failed');
$this->flushSession();
Event::fake();
$loginFailsCacheKey = sha1('login_fails_'.get_client_ip());
// Logging in should be failed if password is wrong
@ -90,6 +112,24 @@ class AuthControllerTest extends TestCase
]
);
$this->assertTrue(Cache::has($loginFailsCacheKey));
Event::assertDispatched('auth.login.attempt', function ($event, $payload) use ($user) {
$this->assertEquals($user->email, $payload[0]);
$this->assertEquals('wrong-password', $payload[1]);
$this->assertEquals('email', $payload[2]);
return true;
});
Event::assertDispatched('auth.login.ready', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
Event::assertDispatched('auth.login.failed', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
$this->assertEquals(1, $payload[1]);
return true;
});
$this->flushSession();
@ -104,18 +144,6 @@ class AuthControllerTest extends TestCase
Cache::flush();
$this->flushSession();
// Should return a warning if user isn't existed
$this->postJson(
'/auth/login', [
'identification' => 'nope@nope.net',
'password' => '12345678',
])->assertJson([
'code' => 2,
'message' => trans('auth.validation.user'),
]);
$this->flushSession();
// Should clean the `login_fails` session if logged in successfully
Cache::put($loginFailsCacheKey, 1);
$this->postJson('/auth/login', [
@ -128,6 +156,16 @@ class AuthControllerTest extends TestCase
]
);
$this->assertFalse(Cache::has($loginFailsCacheKey));
Event::assertDispatched('auth.login.ready', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
Event::assertDispatched('auth.login.succeeded', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
Event::assertDispatched(Events\UserTryToLogin::class);
Event::assertDispatched(Events\UserLoggedIn::class);
@ -152,11 +190,7 @@ class AuthControllerTest extends TestCase
public function testLogout()
{
$this->postJson('/auth/logout')
->assertJson([
'code' => 1,
'message' => trans('auth.logout.fail'),
]);
Event::fake();
$user = factory(User::class)->create();
$this->actingAs($user)->postJson('/auth/logout')->assertJson(
@ -166,6 +200,16 @@ class AuthControllerTest extends TestCase
]
);
$this->assertGuest();
Event::assertDispatched('auth.logout.before', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
Event::assertDispatched('auth.logout.after', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
}
public function testRegister()
@ -269,6 +313,15 @@ class AuthControllerTest extends TestCase
'message' => trans('user.player.add.repeated'),
]);
$this->assertNull(User::where('email', 'a@b.c')->first());
Event::assertDispatched('auth.registration.attempt', function ($event, $payload) {
[$data] = $payload;
$this->assertEquals('a@b.c', $data['email']);
$this->assertEquals('12345678', $data['password']);
return true;
});
Event::assertNotDispatched('auth.registration.ready');
Event::assertNotDispatched('auth.registration.completed');
option(['register_with_player_name' => false]);
@ -318,11 +371,7 @@ class AuthControllerTest extends TestCase
'message' => trans('auth.register.close'),
]);
// Reopen for test
Option::set('user_can_register', true);
// Should be forbidden if registering's count current IP is over
Option::set('regs_per_ip', -1);
option(['user_can_register' => true, 'regs_per_ip' => -1]);
$this->postJson(
'/auth/register',
[
@ -362,7 +411,40 @@ class AuthControllerTest extends TestCase
'permission' => User::NORMAL,
]);
$this->assertAuthenticated();
Event::assertDispatched('auth.registration.attempt', function ($event, $payload) {
[$data] = $payload;
$this->assertEquals('a@b.c', $data['email']);
$this->assertEquals('12345678', $data['password']);
return true;
});
Event::assertDispatched('auth.registration.ready', function ($event, $payload) {
[$data] = $payload;
$this->assertEquals('a@b.c', $data['email']);
$this->assertEquals('12345678', $data['password']);
return true;
});
Event::assertDispatched('auth.registration.completed', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
$this->assertGreaterThan(0, $user->uid);
return true;
});
Event::assertDispatched(Events\UserRegistered::class);
Event::assertDispatched('auth.login.ready', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
Event::assertDispatched('auth.login.succeeded', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
// Require player name
option(['register_with_player_name' => true]);
@ -388,11 +470,13 @@ class AuthControllerTest extends TestCase
public function testHandleForgot()
{
Event::fake();
Mail::fake();
// Should be forbidden if "forgot password" is closed
config(['mail.driver' => '']);
$this->postJson('/auth/forgot', [
'email' => 'nope@nope.net',
'captcha' => 'a',
])->assertJson([
'code' => 1,
@ -405,11 +489,20 @@ class AuthControllerTest extends TestCase
// Should be forbidden if sending email frequently
Cache::put($lastMailCacheKey, time());
$this->postJson('/auth/forgot', [
'email' => 'nope@nope.net',
'captcha' => 'a',
])->assertJson([
'code' => 2,
'message' => trans('auth.forgot.frequent-mail'),
]);
Event::assertDispatched('auth.forgot.attempt', function ($event, $payload) {
$this->assertEquals('nope@nope.net', $payload[0]);
return true;
});
Event::assertNotDispatched('auth.forgot.ready');
Event::assertNotDispatched('auth.forgot.sent');
Event::assertNotDispatched('auth.forgot.sent');
Cache::flush();
$this->flushSession();
@ -423,6 +516,7 @@ class AuthControllerTest extends TestCase
'message' => trans('auth.forgot.unregistered'),
]);
Event::fake();
$this->postJson('/auth/forgot', [
'email' => $user->email,
'captcha' => 'a',
@ -432,11 +526,28 @@ class AuthControllerTest extends TestCase
]);
$this->assertTrue(Cache::has($lastMailCacheKey));
Cache::flush();
Event::assertDispatched('auth.forgot.attempt', function ($event, $payload) use ($user) {
$this->assertEquals($user->email, $payload[0]);
return true;
});
Event::assertDispatched('auth.forgot.ready', function ($event, $payload) use ($user) {
$this->assertEquals($user->email, $payload[0]->email);
return true;
});
Mail::assertSent(ForgotPassword::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
Event::assertDispatched('auth.forgot.sent', function ($event, $payload) use ($user) {
$this->assertEquals($user->email, $payload[0]->email);
$this->assertStringContainsString('auth/reset/'.$user->uid, $payload[1]);
return true;
});
// Should handle exception when sending email
Event::fake();
Mail::shouldReceive('to')
->once()
->andThrow(new \Mockery\Exception('A fake exception.'));
@ -449,6 +560,13 @@ class AuthControllerTest extends TestCase
'code' => 2,
'message' => trans('auth.forgot.failed', ['msg' => 'A fake exception.']),
]);
Event::assertNotDispatched('auth.forgot.sent');
Event::assertDispatched('auth.forgot.failed', function ($event, $payload) use ($user) {
$this->assertEquals($user->email, $payload[0]->email);
$this->assertStringContainsString('auth/reset/'.$user->uid, $payload[1]);
return true;
});
// Addition: Mailable test
$site_name = option_localized('site_name');
@ -470,6 +588,8 @@ class AuthControllerTest extends TestCase
public function testHandleReset()
{
Event::fake();
$user = factory(User::class)->create();
$url = URL::temporarySignedRoute('auth.reset', now()->addHour(), ['uid' => $user->uid]);
@ -477,30 +597,32 @@ class AuthControllerTest extends TestCase
$this->postJson($url)->assertJsonValidationErrors('password');
// Should return a warning if `password` is too short
$this->postJson(
$url, [
'password' => '123',
])->assertJsonValidationErrors('password');
$this->postJson($url, ['password' => '123'])
->assertJsonValidationErrors('password');
// Should return a warning if `password` is too long
$this->postJson(
$url, [
'password' => Str::random(33),
])->assertJsonValidationErrors('password');
$this->postJson($url, ['password' => Str::random(33)])
->assertJsonValidationErrors('password');
// Success
$this->postJson(
$url, [
'password' => '12345678',
])->assertJson([
$this->postJson($url, ['password' => '12345678'])->assertJson([
'code' => 0,
'message' => trans('auth.reset.success'),
]);
// We must re-query the user model,
// because the old instance hasn't been changed
// after resetting password.
$user = User::find($user->uid);
$user->refresh();
$this->assertTrue($user->verifyPassword('12345678'));
Event::assertDispatched('auth.reset.before', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
$this->assertEquals('12345678', $payload[1]);
return true;
});
Event::assertDispatched('auth.reset.after', function ($event, $payload) use ($user) {
$this->assertEquals($user->uid, $payload[0]->uid);
$this->assertEquals('12345678', $payload[1]);
return true;
});
}
public function testCaptcha()
@ -664,14 +786,45 @@ class AuthControllerTest extends TestCase
'permission' => User::NORMAL,
'verified' => true,
]);
Event::assertDispatched(Events\UserRegistered::class);
$this->assertAuthenticated();
Event::assertDispatched('auth.registration.completed', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
$this->assertEquals(1, $user->uid);
return true;
});
Event::assertDispatched('auth.login.ready', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
Event::assertDispatched('auth.login.succeeded', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
auth()->logout();
$this->assertGuest();
Event::fake();
$this->get('/auth/login/github/callback')->assertRedirect('/user');
Event::assertDispatched(Events\UserLoggedIn::class);
$this->assertAuthenticated();
Event::assertNotDispatched('auth.registration.completed');
Event::assertDispatched('auth.login.ready', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
Event::assertDispatched('auth.login.succeeded', function ($event, $payload) {
[$user] = $payload;
$this->assertEquals('a@b.c', $user->email);
return true;
});
}
}