diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php index 01f3620..34eda1f 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php @@ -28,6 +28,17 @@ protected static function getPriority() { } /** + * Handles a 400 error for JSON. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function on400(GetResponseForExceptionEvent $event) { + $response = new JsonResponse(array('message' => $event->getException()->getMessage()), Response::HTTP_BAD_REQUEST); + $event->setResponse($response); + } + + /** * Handles a 403 error for JSON. * * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event diff --git a/core/modules/user/src/Controller/UserLoginController.php b/core/modules/user/src/Controller/UserLoginController.php new file mode 100644 index 0000000..52b5673 --- /dev/null +++ b/core/modules/user/src/Controller/UserLoginController.php @@ -0,0 +1,277 @@ +flood = $flood; + $this->userStorage = $user_storage; + $this->csrfToken = $csrf_token; + $this->userAuth = $user_auth; + $this->serializerFormats = $serializer_formats; + $this->serializer = $serializer; + + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + if ($container->hasParameter('serializer.formats') && $container->has('serializer')) { + $serializer = $container->get('serializer'); + $formats = $container->getParameter('serializer.formats'); + } + else { + $formats = ['json', 'xml']; + $encoders = [new JsonEncoder(), new XmlEncoder()]; + $serializer = new Serializer([], $encoders); + } + + return new static( + $container->get('flood'), + $container->get('entity_type.manager')->getStorage('user'), + $container->get('csrf_token'), + $container->get('user.auth'), + $serializer, + $formats + ); + } + + /** + * Controller to login a user. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return \Symfony\Component\HttpFoundation\Response + * Returns a response which contains the ID and CSRF token. + */ + public function login(Request $request) { + $format = $this->getRequestFormat($request); + + $content = $request->getContent(); + $credentials = $this->serializer->decode($content, $format); + if (!isset($credentials['name']) && !isset($credentials['pass'])) { + throw new BadRequestHttpException('Missing credentials.'); + } + + if (!isset($credentials['name'])) { + throw new BadRequestHttpException('Missing credentials.name.'); + } + if (!isset($credentials['pass'])) { + throw new BadRequestHttpException('Missing credentials.pass.'); + } + + if (!$this->isFloodBlocked()) { + throw new BadRequestHttpException('Blocked.'); + } + + if ($this->userIsBlocked($credentials['name'])) { + throw new BadRequestHttpException('The user has not been activated or is blocked.'); + } + + if ($uid = $this->userAuth->authenticate($credentials['name'], $credentials['pass'])) { + /** @var \Drupal\user\UserInterface $user */ + $user = $this->userStorage->load($uid); + $this->userLoginFinalize($user); + + // Send basic metadata about the logged in user. + $response_data = []; + if ($user->get('uid')->access('view')) { + $response_data['current_user']['uid'] = $user->id(); + } + if ($user->get('roles')->access('view')) { + $response_data['current_user']['roles'] = $user->getRoles(); + } + if ($user->get('name')->access('view')) { + $response_data['current_user']['name'] = $user->getAccountName(); + } + $response_data['csrf_token'] = $this->csrfToken->get('rest'); + + $encoded_response_data = $this->serializer->encode($response_data, $format); + return new Response($encoded_response_data); + } + + $this->flood->register('rest.login_cookie', $this->configFactory->get('user.flood')->get('user_window')); + throw new BadRequestHttpException('Sorry, unrecognized username or password.'); + } + + /** + * Verifies if the user is blocked. + * + * @param string $name + * The username. + * + * @return bool + * Returns TRUE if the user is blocked, otherwise FALSE. + */ + protected function userIsBlocked($name) { + return user_is_blocked($name); + } + + /** + * Finalizes the user login. + * + * @param \Drupal\user\UserInterface $user + * The user. + */ + protected function userLoginFinalize(UserInterface $user) { + user_login_finalize($user); + } + + /** + * Checks for flooding. + * + * @return bool + * TRUE if the user is allowed to proceed, FALSE otherwise. + */ + protected function isFloodBlocked() { + $config = $this->config('user.flood'); + $limit = $config->get('user_limit'); + $interval = $config->get('user_window'); + + return $this->flood->isAllowed('rest.login_cookie', $limit, $interval); + } + + + /** + * Controller to logout a user. + * + * @return \Drupal\rest\ResourceResponse + */ + public function logout() { + $this->userLogout(); + return new Response(NULL, 204); + } + + /** + * Logs the user out. + */ + protected function userLogout() { + user_logout(); + } + + /** + * Controller to show whether a user is logged in or not. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function loginStatus() { + if ($this->currentUser()->isAuthenticated()) { + $response = new Response(self::LOGGED_IN); + } + else { + $response = new Response(self::LOGGED_OUT); + } + $response->headers->set('Content-Type', 'text/plain'); + return $response; + } + + /** + * Gets the format of the current request. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return string + * The format of the request. + */ + protected function getRequestFormat(Request $request) { + $format = $request->getRequestFormat(); + if (!in_array($format, $this->serializerFormats)) { + throw new BadRequestHttpException("Unrecognized format: $format."); + } + return $format; + } + +} diff --git a/core/modules/user/tests/src/Functional/UserLoginHttpTest.php b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php new file mode 100644 index 0000000..2fb1322 --- /dev/null +++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php @@ -0,0 +1,201 @@ +cookies = new CookieJar(); + + $this->serializer = new Serializer([], [new JsonEncoder()]); + } + + /** + * Executes a login HTTP request. + * + * @param string $name + * The username. + * @param $pass + * The user password. + * @ + * + * @return \Psr\Http\Message\ResponseInterface + * The HTTP response. + */ + protected function loginRequest($name, $pass, $format) { + $user_login_url = Url::fromRoute('user.login.http') + ->setRouteParameter('_format', $format) + ->setAbsolute(); + + $request_body = []; + if (isset($name)) { + $request_body['name'] = $name; + } + if (isset($pass)) { + $request_body['pass'] = $pass; + } + + $result = \Drupal::httpClient()->post($user_login_url->toString(), [ + 'body' => $this->encode($request_body, $format), + 'headers' => [ + 'Accept' => "application/$format", + ], + 'http_errors' => FALSE, + 'cookies' => $this->cookies, + ]); + return $result; + } + + /** + * Test user session life cycle. + */ + public function testLogin() { + $account = $this->drupalCreateUser(); + $name = $account->getUsername(); + $pass = $account->passRaw; + + + $client = \Drupal::httpClient(); + + $formats = ['json']; + foreach ($formats as $format) { + + $user_login_status_url = Url::fromRoute('user.login_status.http'); + $user_login_status_url->setRouteParameter('_format', $format); + $user_login_status_url->setAbsolute(); + + $result = $client->post($user_login_status_url->toString()); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals(UserLoginController::LOGGED_OUT, (string) $result->getBody()); + + // Flooded. + \Drupal::configFactory()->getEditable('user.flood') + ->set('user_limit', 3) + ->save(); + + $result = $this->loginRequest($name, 'wrong-pass', $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Sorry, unrecognized username or password."}', (string) $result->getBody()); + + $result = $this->loginRequest($name, 'wrong-pass', $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Sorry, unrecognized username or password."}', (string) $result->getBody()); + + $result = $this->loginRequest($name, 'wrong-pass', $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Sorry, unrecognized username or password."}', (string) $result->getBody()); + + $result = $this->loginRequest($name, 'wrong-pass', $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Blocked."}', (string) $result->getBody()); + + // After testing the flood control we can increase the limit. + \Drupal::configFactory()->getEditable('user.flood') + ->set('user_limit', 100) + ->save(); + + $result = $this->loginRequest(NULL, NULL, $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Missing credentials."}', (string) $result->getBody()); + + $result = $this->loginRequest(NULL, $pass, $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Missing credentials.name."}', (string) $result->getBody()); + + $result = $this->loginRequest($name, NULL, $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Missing credentials.pass."}', (string) $result->getBody()); + + // Blocked. + $account + ->block() + ->save(); + + $result = $this->loginRequest($name, $pass, $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"The user has not been activated or is blocked."}', (string) $result->getBody()); + + $account + ->activate() + ->save(); + + $result = $this->loginRequest($name, 'garbage', $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Sorry, unrecognized username or password."}', (string) $result->getBody()); + + $result = $this->loginRequest('garbage', $pass, $format); + $this->assertEquals(400, $result->getStatusCode()); + $this->assertEquals('{"message":"Sorry, unrecognized username or password."}', (string) $result->getBody()); + + $result = $this->loginRequest($name, $pass, $format); + $this->assertEquals(200, $result->getStatusCode()); + $result_data = $this->decode($result->getBody(), $format); + $this->assertEquals($name, $result_data['current_user']['name']); + + $result = $client->post($user_login_status_url->toString(), ['cookies' => $this->cookies]); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals(UserLoginController::LOGGED_IN, (string) $result->getBody()); + + $user_logout_url = Url::fromRoute('user.logout.http')->setRouteParameter('_format', $format)->setAbsolute(); + $result = $client->post($user_logout_url->toString(), [ + 'headers' => [ + 'Accept' => "application/$format", + ], + 'http_errors' => FALSE, + 'cookies' => $this->cookies, + ]); + $this->assertEquals(204, $result->getStatusCode()); + + $result = $client->post($user_login_status_url->toString(), ['cookies' => $this->cookies]); + $this->assertEquals(200, $result->getStatusCode()); + $this->assertEquals(UserLoginController::LOGGED_OUT, (string) $result->getBody()); + } + + } + + /** + * Encodes data for a request into a given format. + * + * @param $data + * The data to be encoded + * @param $format + * The format to be encoded into. + */ + protected function encode($data, $format) { + return $this->serializer->encode($data, $format); + } + + protected function decode($data, $format) { + return $this->serializer->decode($data, $format); + } + +} diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index 6eea7ec..27f98ae 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -129,6 +129,30 @@ user.login: options: _maintenance_access: TRUE +user.login.http: + path: '/user/login/http' + defaults: + _controller: \Drupal\user\Controller\UserLoginController::login + methods: [POST] + requirements: + _user_is_logged_in: 'FALSE' + +user.login_status.http: + path: '/user/login/status' + defaults: + _controller: \Drupal\user\Controller\UserLoginController::loginStatus + methods: [POST] + requirements: + _access: 'TRUE' + +user.logout.http: + path: '/user/logout/http' + defaults: + _controller: \Drupal\user\Controller\UserLoginController::logout + methods: [POST] + requirements: + _user_is_logged_in: 'TRUE' + user.cancel_confirm: path: '/user/{user}/cancel/confirm/{timestamp}/{hashed_pass}' defaults: