diff --git a/core/BackgroundJobs/CleanupLoginFlowV2.php b/core/BackgroundJobs/CleanupLoginFlowV2.php new file mode 100644 index 00000000000..79d8c5c043b --- /dev/null +++ b/core/BackgroundJobs/CleanupLoginFlowV2.php @@ -0,0 +1,46 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\BackgroundJobs; + +use OC\Core\Db\LoginFlowV2Mapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; + +class CleanupLoginFlowV2 extends TimedJob { + + /** @var LoginFlowV2Mapper */ + private $loginFlowV2Mapper; + + public function __construct(ITimeFactory $time, LoginFlowV2Mapper $loginFlowV2Mapper) { + parent::__construct($time); + $this->loginFlowV2Mapper = $loginFlowV2Mapper; + + $this->setInterval(3600); + } + + protected function run($argument) { + $this->loginFlowV2Mapper->cleanup(); + } +} diff --git a/core/Controller/ClientFlowLoginV2Controller.php b/core/Controller/ClientFlowLoginV2Controller.php new file mode 100644 index 00000000000..cb73b3241a0 --- /dev/null +++ b/core/Controller/ClientFlowLoginV2Controller.php @@ -0,0 +1,299 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Controller; + +use OC\Core\Db\LoginFlowV2; +use OC\Core\Exception\LoginFlowV2NotFoundException; +use OC\Core\Service\LoginFlowV2Service; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StandaloneTemplateResponse; +use OCP\Defaults; +use OCP\IL10N; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\Security\ISecureRandom; + +class ClientFlowLoginV2Controller extends Controller { + + private const tokenName = 'client.flow.v2.login.token'; + private const stateName = 'client.flow.v2.state.token'; + + /** @var LoginFlowV2Service */ + private $loginFlowV2Service; + /** @var IURLGenerator */ + private $urlGenerator; + /** @var ISession */ + private $session; + /** @var ISecureRandom */ + private $random; + /** @var Defaults */ + private $defaults; + /** @var string */ + private $userId; + /** @var IL10N */ + private $l10n; + + public function __construct(string $appName, + IRequest $request, + LoginFlowV2Service $loginFlowV2Service, + IURLGenerator $urlGenerator, + ISession $session, + ISecureRandom $random, + Defaults $defaults, + ?string $userId, + IL10N $l10n) { + parent::__construct($appName, $request); + $this->loginFlowV2Service = $loginFlowV2Service; + $this->urlGenerator = $urlGenerator; + $this->session = $session; + $this->random = $random; + $this->defaults = $defaults; + $this->userId = $userId; + $this->l10n = $l10n; + } + + /** + * @NoCSRFRequired + * @PublicPage + */ + public function poll(string $token): JSONResponse { + try { + $creds = $this->loginFlowV2Service->poll($token); + } catch (LoginFlowV2NotFoundException $e) { + return new JSONResponse([], Http::STATUS_NOT_FOUND); + } + + return new JSONResponse($creds); + } + + /** + * @NoCSRFRequired + * @PublicPage + * @UseSession + */ + public function landing(string $token): Response { + if (!$this->loginFlowV2Service->startLoginFlow($token)) { + return $this->loginTokenForbiddenResponse(); + } + + $this->session->set(self::tokenName, $token); + + return new RedirectResponse( + $this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.showAuthPickerPage') + ); + } + + /** + * @NoCSRFRequired + * @PublicPage + * @UseSession + */ + public function showAuthPickerPage(): StandaloneTemplateResponse { + try { + $flow = $this->getFlowByLoginToken(); + } catch (LoginFlowV2NotFoundException $e) { + return $this->loginTokenForbiddenResponse(); + } + + $stateToken = $this->random->generate( + 64, + ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS + ); + $this->session->set(self::stateName, $stateToken); + + return new StandaloneTemplateResponse( + $this->appName, + 'loginflowv2/authpicker', + [ + 'client' => $flow->getClientName(), + 'instanceName' => $this->defaults->getName(), + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => $stateToken, + ], + 'guest' + ); + } + + /** + * @NoAdminRequired + * @UseSession + * @NoCSRFRequired + * @NoSameSiteCookieRequired + */ + public function grantPage(string $stateToken): StandaloneTemplateResponse { + if(!$this->isValidStateToken($stateToken)) { + return $this->stateTokenForbiddenResponse(); + } + + try { + $flow = $this->getFlowByLoginToken(); + } catch (LoginFlowV2NotFoundException $e) { + return $this->loginTokenForbiddenResponse(); + } + + return new StandaloneTemplateResponse( + $this->appName, + 'loginflowv2/grant', + [ + 'client' => $flow->getClientName(), + 'instanceName' => $this->defaults->getName(), + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => $stateToken, + ], + 'guest' + ); + } + + /** + * @NoAdminRequired + * @UseSession + */ + public function generateAppPassword(string $stateToken): Response { + if(!$this->isValidStateToken($stateToken)) { + return $this->stateTokenForbiddenResponse(); + } + + try { + $this->getFlowByLoginToken(); + } catch (LoginFlowV2NotFoundException $e) { + return $this->loginTokenForbiddenResponse(); + } + + $loginToken = $this->session->get(self::tokenName); + + // Clear session variables + $this->session->remove(self::tokenName); + $this->session->remove(self::stateName); + $sessionId = $this->session->getId(); + + $result = $this->loginFlowV2Service->flowDone($loginToken, $sessionId, $this->getServerPath(), $this->userId); + + if ($result) { + return new StandaloneTemplateResponse( + $this->appName, + 'loginflowv2/done', + [], + 'guest' + ); + } + + $response = new StandaloneTemplateResponse( + $this->appName, + '403', + [ + 'message' => $this->l10n->t('Could not complete login'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + /** + * @NoCSRFRequired + * @PublicPage + */ + public function init(): JSONResponse { + // Get client user agent + $userAgent = $this->request->getHeader('USER_AGENT'); + + $tokens = $this->loginFlowV2Service->createTokens($userAgent); + + $data = [ + 'poll' => [ + 'token' => $tokens->getPollToken(), + 'endpoint' => $this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.poll') + ], + 'login' => $this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.landing', ['token' => $tokens->getLoginToken()]), + ]; + + return new JSONResponse($data); + } + + private function isValidStateToken(string $stateToken): bool { + $currentToken = $this->session->get(self::stateName); + if(!is_string($stateToken) || !is_string($currentToken)) { + return false; + } + return hash_equals($currentToken, $stateToken); + } + + private function stateTokenForbiddenResponse(): StandaloneTemplateResponse { + $response = new StandaloneTemplateResponse( + $this->appName, + '403', + [ + 'message' => $this->l10n->t('State token does not match'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + /** + * @return LoginFlowV2 + * @throws LoginFlowV2NotFoundException + */ + private function getFlowByLoginToken(): LoginFlowV2 { + $currentToken = $this->session->get(self::tokenName); + if(!is_string($currentToken)) { + throw new LoginFlowV2NotFoundException('Login token not set in session'); + } + + return $this->loginFlowV2Service->getByLoginToken($currentToken); + } + + private function loginTokenForbiddenResponse(): StandaloneTemplateResponse { + $response = new StandaloneTemplateResponse( + $this->appName, + '403', + [ + 'message' => $this->l10n->t('Your login token is invalid or has expired'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + private function getServerPath(): string { + $serverPostfix = ''; + + if (strpos($this->request->getRequestUri(), '/index.php') !== false) { + $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/index.php')); + } else if (strpos($this->request->getRequestUri(), '/login/v2') !== false) { + $serverPostfix = substr($this->request->getRequestUri(), 0, strpos($this->request->getRequestUri(), '/login/v2')); + } + + $protocol = $this->request->getServerProtocol(); + return $protocol . '://' . $this->request->getServerHost() . $serverPostfix; + } +} diff --git a/core/Data/LoginFlowV2Credentials.php b/core/Data/LoginFlowV2Credentials.php new file mode 100644 index 00000000000..68dd772f9e0 --- /dev/null +++ b/core/Data/LoginFlowV2Credentials.php @@ -0,0 +1,71 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Data; + +class LoginFlowV2Credentials implements \JsonSerializable { + /** @var string */ + private $server; + /** @var string */ + private $loginName; + /** @var string */ + private $appPassword; + + public function __construct(string $server, string $loginName, string $appPassword) { + $this->server = $server; + $this->loginName = $loginName; + $this->appPassword = $appPassword; + } + + /** + * @return string + */ + public function getServer(): string { + return $this->server; + } + + /** + * @return string + */ + public function getLoginName(): string { + return $this->loginName; + } + + /** + * @return string + */ + public function getAppPassword(): string { + return $this->appPassword; + } + + public function jsonSerialize(): array { + return [ + 'server' => $this->server, + 'loginName' => $this->loginName, + 'appPassword' => $this->appPassword, + ]; + } + + +} diff --git a/core/Data/LoginFlowV2Tokens.php b/core/Data/LoginFlowV2Tokens.php new file mode 100644 index 00000000000..e32278d2e7f --- /dev/null +++ b/core/Data/LoginFlowV2Tokens.php @@ -0,0 +1,47 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Data; + +class LoginFlowV2Tokens { + + /** @var string */ + private $loginToken; + /** @var string */ + private $pollToken; + + public function __construct(string $loginToken, string $pollToken) { + $this->loginToken = $loginToken; + $this->pollToken = $pollToken; + } + + public function getPollToken(): string { + return $this->pollToken; + + } + + public function getLoginToken(): string { + return $this->loginToken; + } +} diff --git a/core/Db/LoginFlowV2.php b/core/Db/LoginFlowV2.php new file mode 100644 index 00000000000..07ecb659c44 --- /dev/null +++ b/core/Db/LoginFlowV2.php @@ -0,0 +1,85 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method int getTimestamp() + * @method void setTimestamp(int $timestamp) + * @method int getStarted() + * @method void setStarted(int $started) + * @method string getPollToken() + * @method void setPollToken(string $token) + * @method string getLoginToken() + * @method void setLoginToken(string $token) + * @method string getPublicKey() + * @method void setPublicKey(string $key) + * @method string getPrivateKey() + * @method void setPrivateKey(string $key) + * @method string getClientName() + * @method void setClientName(string $clientName) + * @method string getLoginName() + * @method void setLoginName(string $loginName) + * @method string getServer() + * @method void setServer(string $server) + * @method string getAppPassword() + * @method void setAppPassword(string $appPassword) + */ +class LoginFlowV2 extends Entity { + /** @var int */ + protected $timestamp; + /** @var int */ + protected $started; + /** @var string */ + protected $pollToken; + /** @var string */ + protected $loginToken; + /** @var string */ + protected $publicKey; + /** @var string */ + protected $privateKey; + /** @var string */ + protected $clientName; + /** @var string */ + protected $loginName; + /** @var string */ + protected $server; + /** @var string */ + protected $appPassword; + + public function __construct() { + $this->addType('timestamp', 'int'); + $this->addType('started', 'int'); + $this->addType('pollToken', 'string'); + $this->addType('loginToken', 'string'); + $this->addType('publicKey', 'string'); + $this->addType('privateKey', 'string'); + $this->addType('clientName', 'string'); + $this->addType('loginName', 'string'); + $this->addType('server', 'string'); + $this->addType('appPassword', 'string'); + } +} diff --git a/core/Db/LoginFlowV2Mapper.php b/core/Db/LoginFlowV2Mapper.php new file mode 100644 index 00000000000..a9104557a76 --- /dev/null +++ b/core/Db/LoginFlowV2Mapper.php @@ -0,0 +1,100 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IDBConnection; + +class LoginFlowV2Mapper extends QBMapper { + private const lifetime = 1200; + + /** @var ITimeFactory */ + private $timeFactory; + + public function __construct(IDBConnection $db, ITimeFactory $timeFactory) { + parent::__construct($db, 'login_flow_v2', LoginFlowV2::class); + $this->timeFactory = $timeFactory; + } + + /** + * @param string $pollToken + * @return LoginFlowV2 + * @throws DoesNotExistException + */ + public function getByPollToken(string $pollToken): LoginFlowV2 { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('poll_token', $qb->createNamedParameter($pollToken)) + ); + + $entity = $this->findEntity($qb); + return $this->validateTimestamp($entity); + } + + /** + * @param string $loginToken + * @return LoginFlowV2 + * @throws DoesNotExistException + */ + public function getByLoginToken(string $loginToken): LoginFlowV2 { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('login_token', $qb->createNamedParameter($loginToken)) + ); + + $entity = $this->findEntity($qb); + return $this->validateTimestamp($entity); + } + + public function cleanup(): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->lt('timestamp', $qb->createNamedParameter($this->timeFactory->getTime() - self::lifetime)) + ); + + $qb->execute(); + } + + /** + * @param LoginFlowV2 $flowV2 + * @return LoginFlowV2 + * @throws DoesNotExistException + */ + private function validateTimestamp(LoginFlowV2 $flowV2): LoginFlowV2 { + if ($flowV2->getTimestamp() < ($this->timeFactory->getTime() - self::lifetime)) { + $this->delete($flowV2); + throw new DoesNotExistException('Token expired'); + } + + return $flowV2; + } +} diff --git a/core/Exception/LoginFlowV2NotFoundException.php b/core/Exception/LoginFlowV2NotFoundException.php new file mode 100644 index 00000000000..1e2bbb761ef --- /dev/null +++ b/core/Exception/LoginFlowV2NotFoundException.php @@ -0,0 +1,29 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Exception; + +class LoginFlowV2NotFoundException extends \Exception { + +} diff --git a/core/Migrations/Version16000Date20190212081545.php b/core/Migrations/Version16000Date20190212081545.php new file mode 100644 index 00000000000..6f6902bf177 --- /dev/null +++ b/core/Migrations/Version16000Date20190212081545.php @@ -0,0 +1,101 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Migrations; + +use Closure; +use Doctrine\DBAL\Types\Type; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\SimpleMigrationStep; +use OCP\Migration\IOutput; + +class Version16000Date20190212081545 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->createTable('login_flow_v2'); + $table->addColumn('id', Type::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('timestamp', Type::BIGINT, [ + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('started', Type::SMALLINT, [ + 'notnull' => true, + 'length' => 1, + 'unsigned' => true, + 'default' => 0, + ]); + $table->addColumn('poll_token', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('login_token', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('public_key', Type::TEXT, [ + 'notnull' => true, + 'length' => 32768, + ]); + $table->addColumn('private_key', Type::TEXT, [ + 'notnull' => true, + 'length' => 32768, + ]); + $table->addColumn('client_name', Type::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('login_name', Type::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('server', Type::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->addColumn('app_password', Type::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['poll_token']); + $table->addUniqueIndex(['login_token']); + $table->addIndex(['timestamp']); + + return $schema; + } +} diff --git a/core/Service/LoginFlowV2Service.php b/core/Service/LoginFlowV2Service.php new file mode 100644 index 00000000000..d8912adfa02 --- /dev/null +++ b/core/Service/LoginFlowV2Service.php @@ -0,0 +1,260 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Service; + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OC\Core\Data\LoginFlowV2Credentials; +use OC\Core\Data\LoginFlowV2Tokens; +use OC\Core\Db\LoginFlowV2; +use OC\Core\Db\LoginFlowV2Mapper; +use OC\Core\Exception\LoginFlowV2NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\ILogger; +use OCP\Security\ICrypto; +use OCP\Security\ISecureRandom; + +class LoginFlowV2Service { + + /** @var LoginFlowV2Mapper */ + private $mapper; + /** @var ISecureRandom */ + private $random; + /** @var ITimeFactory */ + private $time; + /** @var IConfig */ + private $config; + /** @var ICrypto */ + private $crypto; + /** @var ILogger */ + private $logger; + /** @var IProvider */ + private $tokenProvider; + + public function __construct(LoginFlowV2Mapper $mapper, + ISecureRandom $random, + ITimeFactory $time, + IConfig $config, + ICrypto $crypto, + ILogger $logger, + IProvider $tokenProvider) { + $this->mapper = $mapper; + $this->random = $random; + $this->time = $time; + $this->config = $config; + $this->crypto = $crypto; + $this->logger = $logger; + $this->tokenProvider = $tokenProvider; + } + + /** + * @param string $pollToken + * @return LoginFlowV2Credentials + * @throws LoginFlowV2NotFoundException + */ + public function poll(string $pollToken): LoginFlowV2Credentials { + try { + $data = $this->mapper->getByPollToken($this->hashToken($pollToken)); + } catch (DoesNotExistException $e) { + throw new LoginFlowV2NotFoundException('Invalid token'); + } + + $loginName = $data->getLoginName(); + $server = $data->getServer(); + $appPassword = $data->getAppPassword(); + + if ($loginName === null || $server === null || $appPassword === null) { + throw new LoginFlowV2NotFoundException('Token not yet ready'); + } + + // Remove the data from the DB + $this->mapper->delete($data); + + try { + // Decrypt the apptoken + $privateKey = $this->crypto->decrypt($data->getPrivateKey(), $pollToken); + $appPassword = $this->decryptPassword($data->getAppPassword(), $privateKey); + } catch (\Exception $e) { + throw new LoginFlowV2NotFoundException('Apptoken could not be decrypted'); + } + + return new LoginFlowV2Credentials($server, $loginName, $appPassword); + } + + /** + * @param string $loginToken + * @return LoginFlowV2 + * @throws LoginFlowV2NotFoundException + */ + public function getByLoginToken(string $loginToken): LoginFlowV2 { + try { + return $this->mapper->getByLoginToken($loginToken); + } catch (DoesNotExistException $e) { + throw new LoginFlowV2NotFoundException('Login token invalid'); + } + } + + /** + * @param string $loginToken + * @return bool returns true if the start was successfull. False if not. + */ + public function startLoginFlow(string $loginToken): bool { + try { + $data = $this->mapper->getByLoginToken($loginToken); + } catch (DoesNotExistException $e) { + return false; + } + + if ($data->getStarted() !== 0) { + return false; + } + + $data->setStarted(1); + $this->mapper->update($data); + + return true; + } + + /** + * @param string $loginToken + * @param string $sessionId + * @param string $server + * @param string $userId + * @return bool true if the flow was successfully completed false otherwise + */ + public function flowDone(string $loginToken, string $sessionId, string $server, string $userId): bool { + try { + $data = $this->mapper->getByLoginToken($loginToken); + } catch (DoesNotExistException $e) { + return false; + } + + try { + $sessionToken = $this->tokenProvider->getToken($sessionId); + $loginName = $sessionToken->getLoginName(); + try { + $password = $this->tokenProvider->getPassword($sessionToken, $sessionId); + } catch (PasswordlessTokenException $ex) { + $password = null; + } + } catch (InvalidTokenException $ex) { + return false; + } + + $appPassword = $this->random->generate(72, ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_DIGITS); + $this->tokenProvider->generateToken( + $appPassword, + $userId, + $loginName, + $password, + $data->getClientName(), + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + $data->setLoginName($loginName); + $data->setServer($server); + + // Properly encrypt + $data->setAppPassword($this->encryptPassword($appPassword, $data->getPublicKey())); + + $this->mapper->update($data); + return true; + } + + public function createTokens(string $userAgent): LoginFlowV2Tokens { + $flow = new LoginFlowV2(); + $pollToken = $this->random->generate(128, ISecureRandom::CHAR_DIGITS.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER); + $loginToken = $this->random->generate(128, ISecureRandom::CHAR_DIGITS.ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER); + $flow->setPollToken($this->hashToken($pollToken)); + $flow->setLoginToken($loginToken); + $flow->setStarted(0); + $flow->setTimestamp($this->time->getTime()); + $flow->setClientName($userAgent); + + [$publicKey, $privateKey] = $this->getKeyPair(); + $privateKey = $this->crypto->encrypt($privateKey, $pollToken); + + $flow->setPublicKey($publicKey); + $flow->setPrivateKey($privateKey); + + $this->mapper->insert($flow); + + return new LoginFlowV2Tokens($loginToken, $pollToken); + } + + private function hashToken(string $token): string { + $secret = $this->config->getSystemValue('secret'); + return hash('sha512', $token . $secret); + } + + private function getKeyPair(): array { + $config = array_merge([ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + ], $this->config->getSystemValue('openssl', [])); + + // Generate new key + $res = openssl_pkey_new($config); + if ($res === false) { + $this->logOpensslError(); + throw new \RuntimeException('Could not initialize keys'); + } + + openssl_pkey_export($res, $privateKey); + + // Extract the public key from $res to $pubKey + $publicKey = openssl_pkey_get_details($res); + $publicKey = $publicKey['key']; + + return [$publicKey, $privateKey]; + } + + private function logOpensslError(): void { + $errors = []; + while ($error = openssl_error_string()) { + $errors[] = $error; + } + $this->logger->critical('Something is wrong with your openssl setup: ' . implode(', ', $errors)); + } + + private function encryptPassword(string $password, string $publicKey): string { + openssl_public_encrypt($password, $encryptedPassword, $publicKey, OPENSSL_PKCS1_OAEP_PADDING); + $encryptedPassword = base64_encode($encryptedPassword); + + return $encryptedPassword; + } + + private function decryptPassword(string $encryptedPassword, string $privateKey): string { + $encryptedPassword = base64_decode($encryptedPassword); + openssl_private_decrypt($encryptedPassword, $password, $privateKey, OPENSSL_PKCS1_OAEP_PADDING); + + return $password; + } +} diff --git a/core/routes.php b/core/routes.php index c5de63b8f33..d79fea1ca21 100644 --- a/core/routes.php +++ b/core/routes.php @@ -52,10 +52,18 @@ $application->registerRoutes($this, [ ['name' => 'login#confirmPassword', 'url' => '/login/confirm', 'verb' => 'POST'], ['name' => 'login#showLoginForm', 'url' => '/login', 'verb' => 'GET'], ['name' => 'login#logout', 'url' => '/logout', 'verb' => 'GET'], + // Original login flow used by all clients ['name' => 'ClientFlowLogin#showAuthPickerPage', 'url' => '/login/flow', 'verb' => 'GET'], ['name' => 'ClientFlowLogin#generateAppPassword', 'url' => '/login/flow', 'verb' => 'POST'], ['name' => 'ClientFlowLogin#grantPage', 'url' => '/login/flow/grant', 'verb' => 'GET'], ['name' => 'ClientFlowLogin#apptokenRedirect', 'url' => '/login/flow/apptoken', 'verb' => 'POST'], + // NG login flow used by desktop client in case of Kerberos/fancy 2fa (smart cards for example) + ['name' => 'ClientFlowLoginV2#poll', 'url' => '/login/v2/poll', 'verb' => 'POST'], + ['name' => 'ClientFlowLoginV2#showAuthPickerPage', 'url' => '/login/v2/flow', 'verb' => 'GET'], + ['name' => 'ClientFlowLoginV2#landing', 'url' => '/login/v2/flow/{token}', 'verb' => 'GET'], + ['name' => 'ClientFlowLoginV2#grantPage', 'url' => '/login/v2/grant', 'verb' => 'GET'], + ['name' => 'ClientFlowLoginV2#generateAppPassword', 'url' => '/login/v2/grant', 'verb' => 'POST'], + ['name' => 'ClientFlowLoginV2#init', 'url' => '/login/v2', 'verb' => 'POST'], ['name' => 'TwoFactorChallenge#selectChallenge', 'url' => '/login/selectchallenge', 'verb' => 'GET'], ['name' => 'TwoFactorChallenge#showChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'GET'], ['name' => 'TwoFactorChallenge#solveChallenge', 'url' => '/login/challenge/{challengeProviderId}', 'verb' => 'POST'], diff --git a/core/templates/loginflowv2/authpicker.php b/core/templates/loginflowv2/authpicker.php new file mode 100644 index 00000000000..79462eec8dc --- /dev/null +++ b/core/templates/loginflowv2/authpicker.php @@ -0,0 +1,46 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +style('core', 'login/authpicker'); + +/** @var array $_ */ +/** @var \OCP\IURLGenerator $urlGenerator */ +$urlGenerator = $_['urlGenerator']; +?> + +
+

t('Connect to your account')) ?>

+

+ t('Please log in before granting %1$s access to your %2$s account.', [ + '' . \OCP\Util::sanitizeHTML($_['client']) . '', + \OCP\Util::sanitizeHTML($_['instanceName']) + ])) ?> +

+ +
+ + + +
diff --git a/core/templates/loginflowv2/done.php b/core/templates/loginflowv2/done.php new file mode 100644 index 00000000000..aa5fc89f5ab --- /dev/null +++ b/core/templates/loginflowv2/done.php @@ -0,0 +1,39 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +style('core', 'login/authpicker'); + +/** @var array $_ */ +/** @var \OCP\IURLGenerator $urlGenerator */ +$urlGenerator = $_['urlGenerator']; +?> + +
+

t('Account connected')) ?>

+

+ t('Your client should now be connected! You can close this window.')) ?> +

+ +
+
diff --git a/core/templates/loginflowv2/grant.php b/core/templates/loginflowv2/grant.php new file mode 100644 index 00000000000..e5991d11a25 --- /dev/null +++ b/core/templates/loginflowv2/grant.php @@ -0,0 +1,50 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +style('core', 'login/authpicker'); + +/** @var array $_ */ +/** @var \OCP\IURLGenerator $urlGenerator */ +$urlGenerator = $_['urlGenerator']; +?> + +
+

t('Account access')) ?>

+

+ t('You are about to grant %1$s access to your %2$s account.', [ + '' . \OCP\Util::sanitizeHTML($_['client']) . '', + \OCP\Util::sanitizeHTML($_['instanceName']) + ])) ?> +

+ +
+ +
+ + +
+ +
+
+
+

+
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index d74b6d11978..bb1ea11f2e0 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -565,6 +565,7 @@ return array( 'OC\\Contacts\\ContactsMenu\\Providers\\EMailProvider' => $baseDir . '/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php', 'OC\\Core\\Application' => $baseDir . '/core/Application.php', 'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => $baseDir . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php', + 'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => $baseDir . '/core/BackgroundJobs/CleanupLoginFlowV2.php', 'OC\\Core\\Command\\App\\CheckCode' => $baseDir . '/core/Command/App/CheckCode.php', 'OC\\Core\\Command\\App\\Disable' => $baseDir . '/core/Command/App/Disable.php', 'OC\\Core\\Command\\App\\Enable' => $baseDir . '/core/Command/App/Enable.php', @@ -654,6 +655,7 @@ return array( 'OC\\Core\\Controller\\AvatarController' => $baseDir . '/core/Controller/AvatarController.php', 'OC\\Core\\Controller\\CSRFTokenController' => $baseDir . '/core/Controller/CSRFTokenController.php', 'OC\\Core\\Controller\\ClientFlowLoginController' => $baseDir . '/core/Controller/ClientFlowLoginController.php', + 'OC\\Core\\Controller\\ClientFlowLoginV2Controller' => $baseDir . '/core/Controller/ClientFlowLoginV2Controller.php', 'OC\\Core\\Controller\\ContactsMenuController' => $baseDir . '/core/Controller/ContactsMenuController.php', 'OC\\Core\\Controller\\CssController' => $baseDir . '/core/Controller/CssController.php', 'OC\\Core\\Controller\\GuestAvatarController' => $baseDir . '/core/Controller/GuestAvatarController.php', @@ -671,6 +673,11 @@ return array( 'OC\\Core\\Controller\\UserController' => $baseDir . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => $baseDir . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WhatsNewController' => $baseDir . '/core/Controller/WhatsNewController.php', + 'OC\\Core\\Data\\LoginFlowV2Credentials' => $baseDir . '/core/Data/LoginFlowV2Credentials.php', + 'OC\\Core\\Data\\LoginFlowV2Tokens' => $baseDir . '/core/Data/LoginFlowV2Tokens.php', + 'OC\\Core\\Db\\LoginFlowV2' => $baseDir . '/core/Db/LoginFlowV2.php', + 'OC\\Core\\Db\\LoginFlowV2Mapper' => $baseDir . '/core/Db/LoginFlowV2Mapper.php', + 'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => $baseDir . '/core/Exception/LoginFlowV2NotFoundException.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => $baseDir . '/core/Middleware/TwoFactorMiddleware.php', 'OC\\Core\\Migrations\\Version13000Date20170705121758' => $baseDir . '/core/Migrations/Version13000Date20170705121758.php', 'OC\\Core\\Migrations\\Version13000Date20170718121200' => $baseDir . '/core/Migrations/Version13000Date20170718121200.php', @@ -688,6 +695,8 @@ return array( 'OC\\Core\\Migrations\\Version15000Date20180926101451' => $baseDir . '/core/Migrations/Version15000Date20180926101451.php', 'OC\\Core\\Migrations\\Version15000Date20181015062942' => $baseDir . '/core/Migrations/Version15000Date20181015062942.php', 'OC\\Core\\Migrations\\Version15000Date20181029084625' => $baseDir . '/core/Migrations/Version15000Date20181029084625.php', + 'OC\\Core\\Migrations\\Version16000Date20190212081545' => $baseDir . '/core/Migrations/Version16000Date20190212081545.php', + 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => $baseDir . '/lib/private/DB/AdapterOCI8.php', @@ -985,6 +994,7 @@ return array( 'OC\\Repair\\NC14\\AddPreviewBackgroundCleanupJob' => $baseDir . '/lib/private/Repair/NC14/AddPreviewBackgroundCleanupJob.php', 'OC\\Repair\\NC14\\RepairPendingCronJobs' => $baseDir . '/lib/private/Repair/NC14/RepairPendingCronJobs.php', 'OC\\Repair\\NC15\\SetVcardDatabaseUID' => $baseDir . '/lib/private/Repair/NC15/SetVcardDatabaseUID.php', + 'OC\\Repair\\NC16\\AddClenupLoginFlowV2BackgroundJob' => $baseDir . '/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php', 'OC\\Repair\\NC16\\CleanupCardDAVPhotoCache' => $baseDir . '/lib/private/Repair/NC16/CleanupCardDAVPhotoCache.php', 'OC\\Repair\\OldGroupMembershipShares' => $baseDir . '/lib/private/Repair/OldGroupMembershipShares.php', 'OC\\Repair\\Owncloud\\DropAccountTermsTable' => $baseDir . '/lib/private/Repair/Owncloud/DropAccountTermsTable.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index a0a6cb0af3b..91083504565 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -595,6 +595,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Contacts\\ContactsMenu\\Providers\\EMailProvider' => __DIR__ . '/../../..' . '/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php', 'OC\\Core\\Application' => __DIR__ . '/../../..' . '/core/Application.php', 'OC\\Core\\BackgroundJobs\\BackgroundCleanupUpdaterBackupsJob' => __DIR__ . '/../../..' . '/core/BackgroundJobs/BackgroundCleanupUpdaterBackupsJob.php', + 'OC\\Core\\BackgroundJobs\\CleanupLoginFlowV2' => __DIR__ . '/../../..' . '/core/BackgroundJobs/CleanupLoginFlowV2.php', 'OC\\Core\\Command\\App\\CheckCode' => __DIR__ . '/../../..' . '/core/Command/App/CheckCode.php', 'OC\\Core\\Command\\App\\Disable' => __DIR__ . '/../../..' . '/core/Command/App/Disable.php', 'OC\\Core\\Command\\App\\Enable' => __DIR__ . '/../../..' . '/core/Command/App/Enable.php', @@ -684,6 +685,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Controller\\AvatarController' => __DIR__ . '/../../..' . '/core/Controller/AvatarController.php', 'OC\\Core\\Controller\\CSRFTokenController' => __DIR__ . '/../../..' . '/core/Controller/CSRFTokenController.php', 'OC\\Core\\Controller\\ClientFlowLoginController' => __DIR__ . '/../../..' . '/core/Controller/ClientFlowLoginController.php', + 'OC\\Core\\Controller\\ClientFlowLoginV2Controller' => __DIR__ . '/../../..' . '/core/Controller/ClientFlowLoginV2Controller.php', 'OC\\Core\\Controller\\ContactsMenuController' => __DIR__ . '/../../..' . '/core/Controller/ContactsMenuController.php', 'OC\\Core\\Controller\\CssController' => __DIR__ . '/../../..' . '/core/Controller/CssController.php', 'OC\\Core\\Controller\\GuestAvatarController' => __DIR__ . '/../../..' . '/core/Controller/GuestAvatarController.php', @@ -701,6 +703,11 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Controller\\UserController' => __DIR__ . '/../../..' . '/core/Controller/UserController.php', 'OC\\Core\\Controller\\WalledGardenController' => __DIR__ . '/../../..' . '/core/Controller/WalledGardenController.php', 'OC\\Core\\Controller\\WhatsNewController' => __DIR__ . '/../../..' . '/core/Controller/WhatsNewController.php', + 'OC\\Core\\Data\\LoginFlowV2Credentials' => __DIR__ . '/../../..' . '/core/Data/LoginFlowV2Credentials.php', + 'OC\\Core\\Data\\LoginFlowV2Tokens' => __DIR__ . '/../../..' . '/core/Data/LoginFlowV2Tokens.php', + 'OC\\Core\\Db\\LoginFlowV2' => __DIR__ . '/../../..' . '/core/Db/LoginFlowV2.php', + 'OC\\Core\\Db\\LoginFlowV2Mapper' => __DIR__ . '/../../..' . '/core/Db/LoginFlowV2Mapper.php', + 'OC\\Core\\Exception\\LoginFlowV2NotFoundException' => __DIR__ . '/../../..' . '/core/Exception/LoginFlowV2NotFoundException.php', 'OC\\Core\\Middleware\\TwoFactorMiddleware' => __DIR__ . '/../../..' . '/core/Middleware/TwoFactorMiddleware.php', 'OC\\Core\\Migrations\\Version13000Date20170705121758' => __DIR__ . '/../../..' . '/core/Migrations/Version13000Date20170705121758.php', 'OC\\Core\\Migrations\\Version13000Date20170718121200' => __DIR__ . '/../../..' . '/core/Migrations/Version13000Date20170718121200.php', @@ -718,6 +725,8 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Migrations\\Version15000Date20180926101451' => __DIR__ . '/../../..' . '/core/Migrations/Version15000Date20180926101451.php', 'OC\\Core\\Migrations\\Version15000Date20181015062942' => __DIR__ . '/../../..' . '/core/Migrations/Version15000Date20181015062942.php', 'OC\\Core\\Migrations\\Version15000Date20181029084625' => __DIR__ . '/../../..' . '/core/Migrations/Version15000Date20181029084625.php', + 'OC\\Core\\Migrations\\Version16000Date20190212081545' => __DIR__ . '/../../..' . '/core/Migrations/Version16000Date20190212081545.php', + 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', 'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php', 'OC\\DB\\AdapterOCI8' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterOCI8.php', @@ -1015,6 +1024,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Repair\\NC14\\AddPreviewBackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Repair/NC14/AddPreviewBackgroundCleanupJob.php', 'OC\\Repair\\NC14\\RepairPendingCronJobs' => __DIR__ . '/../../..' . '/lib/private/Repair/NC14/RepairPendingCronJobs.php', 'OC\\Repair\\NC15\\SetVcardDatabaseUID' => __DIR__ . '/../../..' . '/lib/private/Repair/NC15/SetVcardDatabaseUID.php', + 'OC\\Repair\\NC16\\AddClenupLoginFlowV2BackgroundJob' => __DIR__ . '/../../..' . '/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php', 'OC\\Repair\\NC16\\CleanupCardDAVPhotoCache' => __DIR__ . '/../../..' . '/lib/private/Repair/NC16/CleanupCardDAVPhotoCache.php', 'OC\\Repair\\OldGroupMembershipShares' => __DIR__ . '/../../..' . '/lib/private/Repair/OldGroupMembershipShares.php', 'OC\\Repair\\Owncloud\\DropAccountTermsTable' => __DIR__ . '/../../..' . '/lib/private/Repair/Owncloud/DropAccountTermsTable.php', diff --git a/lib/private/Repair.php b/lib/private/Repair.php index 72995a96132..e4eb4cfcc16 100644 --- a/lib/private/Repair.php +++ b/lib/private/Repair.php @@ -43,6 +43,7 @@ use OC\Repair\NC13\RepairInvalidPaths; use OC\Repair\NC14\AddPreviewBackgroundCleanupJob; use OC\Repair\NC14\RepairPendingCronJobs; use OC\Repair\NC15\SetVcardDatabaseUID; +use OC\Repair\NC16\AddClenupLoginFlowV2BackgroundJob; use OC\Repair\NC16\CleanupCardDAVPhotoCache; use OC\Repair\OldGroupMembershipShares; use OC\Repair\Owncloud\DropAccountTermsTable; @@ -150,6 +151,7 @@ class Repair implements IOutput { new RepairPendingCronJobs(\OC::$server->getDatabaseConnection(), \OC::$server->getConfig()), new SetVcardDatabaseUID(\OC::$server->getDatabaseConnection(), \OC::$server->getConfig(), \OC::$server->getLogger()), new CleanupCardDAVPhotoCache(\OC::$server->getConfig(), \OC::$server->getAppDataDir('dav-photocache'), \OC::$server->getLogger()), + new AddClenupLoginFlowV2BackgroundJob(\OC::$server->getJobList()), ]; } diff --git a/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php b/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php new file mode 100644 index 00000000000..9f8bdef9b1f --- /dev/null +++ b/lib/private/Repair/NC16/AddClenupLoginFlowV2BackgroundJob.php @@ -0,0 +1,49 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Repair\NC16; + +use OC\Core\BackgroundJobs\CleanupLoginFlowV2; +use OCP\BackgroundJob\IJobList; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; + +class AddClenupLoginFlowV2BackgroundJob implements IRepairStep { + + /** @var IJobList */ + private $jobList; + + public function __construct(IJobList $jobList) { + $this->jobList = $jobList; + } + + public function getName(): string { + return 'Add background job to cleanup login flow v2 tokens'; + } + + public function run(IOutput $output) { + $this->jobList->add(CleanupLoginFlowV2::class); + } + +} diff --git a/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php b/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php new file mode 100644 index 00000000000..911a4923675 --- /dev/null +++ b/tests/Core/Controller/ClientFlowLoginV2ControllerTest.php @@ -0,0 +1,321 @@ + + * + * @author Roeland Jago Douma + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace Test\Core\Controller; + +use OC\Core\Controller\ClientFlowLoginV2Controller; +use OC\Core\Data\LoginFlowV2Credentials; +use OC\Core\Db\LoginFlowV2; +use OC\Core\Exception\LoginFlowV2NotFoundException; +use OC\Core\Service\LoginFlowV2Service; +use OCP\AppFramework\Http; +use OCP\Defaults; +use OCP\IL10N; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\Security\ISecureRandom; +use PHPUnit\Framework\MockObject\MockObject; +use Test\TestCase; + +class ClientFlowLoginV2ControllerTest extends TestCase { + + /** @var IRequest|MockObject */ + private $request; + /** @var LoginFlowV2Service|MockObject */ + private $loginFlowV2Service; + /** @var IURLGenerator|MockObject */ + private $urlGenerator; + /** @var ISession|MockObject */ + private $session; + /** @var ISecureRandom|MockObject */ + private $random; + /** @var Defaults|MockObject */ + private $defaults; + /** @var IL10N|MockObject */ + private $l; + /** @var ClientFlowLoginV2Controller */ + private $controller; + + public function setUp() { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->loginFlowV2Service = $this->createMock(LoginFlowV2Service::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->session = $this->createMock(ISession::class); + $this->random = $this->createMock(ISecureRandom::class); + $this->defaults = $this->createMock(Defaults::class); + $this->l = $this->createMock(IL10N::class); + $this->controller = new ClientFlowLoginV2Controller( + 'core', + $this->request, + $this->loginFlowV2Service, + $this->urlGenerator, + $this->session, + $this->random, + $this->defaults, + 'user', + $this->l + ); + } + + public function testPollInvalid() { + $this->loginFlowV2Service->method('poll') + ->with('token') + ->willThrowException(new LoginFlowV2NotFoundException()); + + $result = $this->controller->poll('token'); + + $this->assertSame([], $result->getData()); + $this->assertSame(Http::STATUS_NOT_FOUND, $result->getStatus()); + } + + public function testPollValid() { + $creds = new LoginFlowV2Credentials('server', 'login', 'pass'); + $this->loginFlowV2Service->method('poll') + ->with('token') + ->willReturn($creds); + + $result = $this->controller->poll('token'); + + $this->assertSame($creds, $result->getData()); + $this->assertSame(Http::STATUS_OK, $result->getStatus()); + } + + public function testLandingInvalid() { + $this->session->expects($this->never()) + ->method($this->anything()); + + $this->loginFlowV2Service->method('startLoginFlow') + ->with('token') + ->willReturn(false); + + $result = $this->controller->landing('token'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + $this->assertInstanceOf(Http\StandaloneTemplateResponse::class, $result); + } + + public function testLandingValid() { + $this->session->expects($this->once()) + ->method('set') + ->with('client.flow.v2.login.token', 'token'); + + $this->loginFlowV2Service->method('startLoginFlow') + ->with('token') + ->willReturn(true); + + $this->urlGenerator->method('linkToRouteAbsolute') + ->with('core.ClientFlowLoginV2.showAuthPickerPage') + ->willReturn('https://server/path'); + + $result = $this->controller->landing('token'); + + $this->assertInstanceOf(Http\RedirectResponse::class, $result); + $this->assertSame(Http::STATUS_SEE_OTHER, $result->getStatus()); + $this->assertSame('https://server/path', $result->getRedirectURL()); + } + + public function testShowAuthPickerNoLoginToken() { + $this->session->method('get') + ->willReturn(null); + + $result = $this->controller->showAuthPickerPage(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testShowAuthPickerInvalidLoginToken() { + $this->session->method('get') + ->with('client.flow.v2.login.token') + ->willReturn('loginToken'); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2NotFoundException()); + + $result = $this->controller->showAuthPickerPage(); + + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testShowAuthPickerValidLoginToken() { + $this->session->method('get') + ->with('client.flow.v2.login.token') + ->willReturn('loginToken'); + + $flow = new LoginFlowV2(); + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willReturn($flow); + + $this->random->method('generate') + ->with(64, ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS) + ->willReturn('random'); + $this->session->expects($this->once()) + ->method('set') + ->with('client.flow.v2.state.token', 'random'); + + $this->controller->showAuthPickerPage(); + } + + public function testGrantPageInvalidStateToken() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + return null; + })); + + $result = $this->controller->grantPage('stateToken'); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testGrantPageInvalidLoginToken() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + })); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2NotFoundException()); + + $result = $this->controller->grantPage('stateToken'); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testGrantPageValid() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + })); + + $flow = new LoginFlowV2(); + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willReturn($flow); + + $result = $this->controller->grantPage('stateToken'); + $this->assertSame(Http::STATUS_OK, $result->getStatus()); + } + + + public function testGenerateAppPasswordInvalidStateToken() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + return null; + })); + + $result = $this->controller->generateAppPassword('stateToken'); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testGenerateAppPassworInvalidLoginToken() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + })); + + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willThrowException(new LoginFlowV2NotFoundException()); + + $result = $this->controller->generateAppPassword('stateToken'); + $this->assertSame(Http::STATUS_FORBIDDEN, $result->getStatus()); + } + + public function testGenerateAppPassworValid() { + $this->session->method('get') + ->will($this->returnCallback(function($name) { + if ($name === 'client.flow.v2.state.token') { + return 'stateToken'; + } + if ($name === 'client.flow.v2.login.token') { + return 'loginToken'; + } + return null; + })); + + $flow = new LoginFlowV2(); + $this->loginFlowV2Service->method('getByLoginToken') + ->with('loginToken') + ->willReturn($flow); + + $clearedState = false; + $clearedLogin = false; + $this->session->method('remove') + ->will($this->returnCallback(function ($name) use (&$clearedLogin, &$clearedState) { + if ($name === 'client.flow.v2.state.token') { + $clearedState = true; + } + if ($name === 'client.flow.v2.login.token') { + $clearedLogin = true; + } + })); + + $this->session->method('getId') + ->willReturn('sessionId'); + + $this->loginFlowV2Service->expects($this->once()) + ->method('flowDone') + ->with( + 'loginToken', + 'sessionId', + 'https://server', + 'user' + )->willReturn(true); + + $this->request->method('getServerProtocol') + ->willReturn('https'); + $this->request->method('getRequestUri') + ->willReturn('/login/v2'); + $this->request->method('getServerHost') + ->willReturn('server'); + + $result = $this->controller->generateAppPassword('stateToken'); + $this->assertSame(Http::STATUS_OK, $result->getStatus()); + + $this->assertTrue($clearedLogin); + $this->assertTrue($clearedState); + } +} + diff --git a/version.php b/version.php index d89c3893a87..971223cd201 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ // between betas, final and RCs. This is _not_ the public version number. Reset minor/patchlevel // when updating major/minor version number. -$OC_Version = array(16, 0, 0, 0); +$OC_Version = array(16, 0, 0, 1); // The human readable string $OC_VersionString = '16.0.0 alpha';