diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 8719e673..668f9f09 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -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'); } } diff --git a/routes/web.php b/routes/web.php index 838861a4..b0ea787d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/HttpTest/ControllersTest/AuthControllerTest.php b/tests/HttpTest/ControllersTest/AuthControllerTest.php index 459669cb..377988e9 100644 --- a/tests/HttpTest/ControllersTest/AuthControllerTest.php +++ b/tests/HttpTest/ControllersTest/AuthControllerTest.php @@ -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; + }); } }