diff --git a/src/app/Http/Controllers/API/UsersController.php b/src/app/Http/Controllers/API/UsersController.php index fff51ec8..caaa49e5 100644 --- a/src/app/Http/Controllers/API/UsersController.php +++ b/src/app/Http/Controllers/API/UsersController.php @@ -1,250 +1,263 @@ middleware('auth:api', ['except' => ['login']]); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { $token = auth()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Display a listing of the resources. * * The user themself, and other user entitlements. * * @return \Illuminate\Http\Response */ public function index() { $user = Auth::user(); if (!$user) { return response()->json(['error' => 'unauthorized'], 401); } $result = [$user]; $user->entitlements()->each( function ($entitlement) { $result[] = User::find($entitlement->user_id); } ); return response()->json($result); } /** * Get the authenticated User * * @return \Illuminate\Http\JsonResponse */ public function info() { $user = $this->guard()->user(); $response = $user->toArray(); $response['statusInfo'] = self::statusInfo($user); return response()->json($response); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { + $v = Validator::make( + $request->all(), + [ + 'email' => 'required|min:2', + 'password' => 'required|min:4', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $credentials = $request->only('email', 'password'); if ($token = $this->guard()->attempt($credentials)) { return $this->respondWithToken($token); } - return response()->json(['error' => 'Unauthorized'], 401); + return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $this->guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { return $this->respondWithToken($this->guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', 'expires_in' => $this->guard()->factory()->getTTL() * 60 ] ); } /** * Display the specified resource. * * @param int $id The account to show information for. * * @return \Illuminate\Http\Response */ public function show($id) { $user = Auth::user(); if (!$user) { return abort(403); } $result = false; $user->entitlements()->each( function ($entitlement) { if ($entitlement->user_id == $id) { $result = true; } } ); if ($user->id == $id) { $result = true; } if (!$result) { return abort(404); } return \App\User::find($id); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $status = 'new'; $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => 'isLdapReady', 'user-imap-ready' => 'isImapReady', ]; if ($user->isDeleted()) { $status = 'deleted'; } elseif ($user->isSuspended()) { $status = 'suspended'; } elseif ($user->isActive()) { $status = 'active'; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if (!$domain->isPublic()) { $steps['domain-new'] = true; $steps['domain-ldap-ready'] = 'isLdapReady'; // $steps['domain-verified'] = 'isVerified'; $steps['domain-confirmed'] = 'isConfirmed'; } // Create a process check list foreach ($steps as $step_name => $func) { $object = strpos($step_name, 'user-') === 0 ? $user : $domain; $step = [ 'label' => $step_name, 'title' => __("app.process-{$step_name}"), 'state' => is_bool($func) ? $func : $object->{$func}(), ]; if ($step_name == 'domain-confirmed' && !$step['state']) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; } return [ 'process' => $process, 'status' => $status, ]; } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } } diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index 1581abd4..ac837d06 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,19 +1,19 @@ 'These credentials do not match our records.', - 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + 'failed' => 'Invalid username or password.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds', 'logoutsuccess' => 'Successfully logged out', ]; diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 9098e75c..f013e945 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,142 +1,147 @@ browse(function (Browser $browser) { $browser->visit(new Home()); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); + // Error message + $browser->with(new Toast(Toast::TYPE_ERROR), function (Browser $browser) { + $browser->assertToastTitle('Error') + ->assertToastMessage('Invalid username or password.') + ->closeToast(); + }); + // Checks if we're still on the logon page - // FIXME: This assertion might be prone to timing issues - // I guess we should wait until some error message appears $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }); $browser->assertVue('data.email', 'john@kolab.org', '@dashboard-component'); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->click('.link-logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->with(new Toast(Toast::TYPE_SUCCESS), function (Browser $browser) { $browser->assertToastTitle('') ->assertToastMessage('Successfully logged out') ->closeToast(); }); }); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index a8c63ff9..92a7ec80 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,176 +1,197 @@ delete(); Domain::where('namespace', 'userscontroller.com')->delete(); } /** * Test fetching current user info */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); } public function testIndex(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); $response = $this->actingAs($userA, 'api')->get("/api/v4/users/{$userA->id}"); $response->assertStatus(200); $response->assertJson(['id' => $userA->id]); $user = factory(User::class)->create(); $response = $this->actingAs($user)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(404); } public function testLogin(): string { + // Request with no data + $response = $this->post("api/auth/login", []); + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertArrayHasKey('email', $json['errors']); + $this->assertArrayHasKey('password', $json['errors']); + + // Request with invalid password + $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; + $response = $this->post("api/auth/login", $post); + $response->assertStatus(401); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame('Invalid username or password.', $json['message']); + + // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } public function testRefresh(): void { // TODO $this->markTestIncomplete(); } public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('new', $result['status']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $user->status |= User::STATUS_ACTIVE; $user->save(); // $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertSame('active', $result['status']); $this->assertCount(6, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); // $this->assertSame('domain-verified', $result['process'][5]['label']); // $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][5]['label']); $this->assertSame(false, $result['process'][5]['state']); $user->status |= User::STATUS_DELETED; $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('deleted', $result['status']); } }