diff --git a/core/Controller/ClientFlowLoginController.php b/core/Controller/ClientFlowLoginController.php new file mode 100644 index 0000000000..ca9c092321 --- /dev/null +++ b/core/Controller/ClientFlowLoginController.php @@ -0,0 +1,238 @@ + + * + * @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\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Defaults; +use OCP\IL10N; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Session\Exceptions\SessionNotAvailableException; + +class ClientFlowLoginController extends Controller { + /** @var IUserSession */ + private $userSession; + /** @var IL10N */ + private $l10n; + /** @var Defaults */ + private $defaults; + /** @var ISession */ + private $session; + /** @var IProvider */ + private $tokenProvider; + /** @var ISecureRandom */ + private $random; + /** @var IURLGenerator */ + private $urlGenerator; + + const stateName = 'client.flow.state.token'; + + /** + * @param string $appName + * @param IRequest $request + * @param IUserSession $userSession + * @param IL10N $l10n + * @param Defaults $defaults + * @param ISession $session + * @param IProvider $tokenProvider + * @param ISecureRandom $random + * @param IURLGenerator $urlGenerator + */ + public function __construct($appName, + IRequest $request, + IUserSession $userSession, + IL10N $l10n, + Defaults $defaults, + ISession $session, + IProvider $tokenProvider, + ISecureRandom $random, + IURLGenerator $urlGenerator) { + parent::__construct($appName, $request); + $this->userSession = $userSession; + $this->l10n = $l10n; + $this->defaults = $defaults; + $this->session = $session; + $this->tokenProvider = $tokenProvider; + $this->random = $random; + $this->urlGenerator = $urlGenerator; + } + + /** + * @return string + */ + private function getClientName() { + return $this->request->getHeader('USER_AGENT') !== null ? $this->request->getHeader('USER_AGENT') : 'unknown'; + } + + /** + * @param string $stateToken + * @return bool + */ + private function isValidToken($stateToken) { + $currentToken = $this->session->get(self::stateName); + if(!is_string($stateToken) || !is_string($currentToken)) { + return false; + } + return hash_equals($currentToken, $stateToken); + } + + /** + * @return TemplateResponse + */ + private function stateTokenForbiddenResponse() { + $response = new TemplateResponse( + $this->appName, + '403', + [ + 'file' => $this->l10n->t('State token does not match'), + ], + 'guest' + ); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + /** + * @PublicPage + * @NoCSRFRequired + * @UseSession + * + * @return TemplateResponse + */ + public function showAuthPickerPage() { + if($this->userSession->isLoggedIn()) { + return new TemplateResponse( + $this->appName, + '403', + [ + 'file' => $this->l10n->t('Auth flow can only be started unauthenticated.'), + ], + 'guest' + ); + } + + $stateToken = $this->random->generate( + 64, + ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS + ); + $this->session->set(self::stateName, $stateToken); + + return new TemplateResponse( + $this->appName, + 'loginflow/authpicker', + [ + 'client' => $this->getClientName(), + 'instanceName' => $this->defaults->getName(), + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => $stateToken, + 'serverHost' => $this->request->getServerHost(), + ], + 'guest' + ); + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @UseSession + * + * @param string $stateToken + * @return TemplateResponse + */ + public function redirectPage($stateToken = '') { + if(!$this->isValidToken($stateToken)) { + return $this->stateTokenForbiddenResponse(); + } + + return new TemplateResponse( + $this->appName, + 'loginflow/redirect', + [ + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => $stateToken, + ], + 'empty' + ); + } + + /** + * @NoAdminRequired + * @UseSession + * + * @param string $stateToken + * @return Http\RedirectResponse|Response + */ + public function generateAppPassword($stateToken) { + if(!$this->isValidToken($stateToken)) { + $this->session->remove(self::stateName); + return $this->stateTokenForbiddenResponse(); + } + + $this->session->remove(self::stateName); + + try { + $sessionId = $this->session->getId(); + } catch (SessionNotAvailableException $ex) { + $response = new Response(); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + try { + $sessionToken = $this->tokenProvider->getToken($sessionId); + $loginName = $sessionToken->getLoginName(); + try { + $password = $this->tokenProvider->getPassword($sessionToken, $sessionId); + } catch (PasswordlessTokenException $ex) { + $password = null; + } + } catch (InvalidTokenException $ex) { + $response = new Response(); + $response->setStatus(Http::STATUS_FORBIDDEN); + return $response; + } + + $token = $this->random->generate(72); + $this->tokenProvider->generateToken( + $token, + $this->userSession->getUser()->getUID(), + $loginName, + $password, + $this->getClientName(), + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + return new Http\RedirectResponse('nc://' . urlencode($loginName) . ':' . urlencode($token) . '@' . $this->request->getServerHost()); + } + +} diff --git a/core/css/login/authpicker.css b/core/css/login/authpicker.css new file mode 100644 index 0000000000..85016ee6a0 --- /dev/null +++ b/core/css/login/authpicker.css @@ -0,0 +1,9 @@ +.picker-window { + display: block; + padding: 10px; + margin-bottom: 20px; + background-color: rgba(0,0,0,.3); + color: #fff; + border-radius: 3px; + cursor: default; +} diff --git a/core/js/login/authpicker.js b/core/js/login/authpicker.js new file mode 100644 index 0000000000..6d8a6bb416 --- /dev/null +++ b/core/js/login/authpicker.js @@ -0,0 +1,13 @@ +jQuery(document).ready(function() { + $('#app-token-login').click(function (e) { + e.preventDefault(); + $(this).addClass('hidden'); + $('#redirect-link').addClass('hidden'); + $('#app-token-login-field').removeClass('hidden'); + }); + + $('#submit-app-token-login').click(function(e) { + e.preventDefault(); + window.location.href = 'nc://' + encodeURIComponent($('#user').val()) + ':' + encodeURIComponent($('#password').val()) + '@' + encodeURIComponent($('#serverHost').val()); + }); +}); diff --git a/core/js/login/redirect.js b/core/js/login/redirect.js new file mode 100644 index 0000000000..ea214feab2 --- /dev/null +++ b/core/js/login/redirect.js @@ -0,0 +1,3 @@ +jQuery(document).ready(function() { + $('#submit-redirect-form').trigger('click'); +}); diff --git a/core/routes.php b/core/routes.php index 7a2d9d750c..93a098c596 100644 --- a/core/routes.php +++ b/core/routes.php @@ -49,6 +49,9 @@ $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'], + ['name' => 'ClientFlowLogin#showAuthPickerPage', 'url' => '/login/flow', 'verb' => 'GET'], + ['name' => 'ClientFlowLogin#redirectPage', 'url' => '/login/flow/redirect', 'verb' => 'GET'], + ['name' => 'ClientFlowLogin#generateAppPassword', 'url' => '/login/flow', '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/loginflow/authpicker.php b/core/templates/loginflow/authpicker.php new file mode 100644 index 0000000000..c5eb6cb316 --- /dev/null +++ b/core/templates/loginflow/authpicker.php @@ -0,0 +1,57 @@ + + * + * @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 . + * + */ + +script('core', 'login/authpicker'); +style('core', 'login/authpicker'); + +/** @var array $_ */ +/** @var \OCP\IURLGenerator $urlGenerator */ +$urlGenerator = $_['urlGenerator']; +?> + +
+

+ t('You are about to grant "%s" access to your %s account.', [$_['client'], $_['instanceName']])) ?> +

+ +
+ + + + +
+ +t('Alternative login using app token')) ?> diff --git a/core/templates/loginflow/redirect.php b/core/templates/loginflow/redirect.php new file mode 100644 index 0000000000..7ef0184f61 --- /dev/null +++ b/core/templates/loginflow/redirect.php @@ -0,0 +1,37 @@ + + * + * @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 . + * + */ +script('core', 'login/redirect'); +style('core', 'login/authpicker'); + +/** @var array $_ */ +/** @var \OCP\IURLGenerator $urlGenerator */ +$urlGenerator = $_['urlGenerator']; +?> + +
+

t('Redirecting …')) ?>

+
+ +
+ + + +
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0d5f067779..516ac7c823 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -444,6 +444,7 @@ return array( 'OC\\Core\\Command\\User\\ResetPassword' => $baseDir . '/core/Command/User/ResetPassword.php', 'OC\\Core\\Command\\User\\Setting' => $baseDir . '/core/Command/User/Setting.php', 'OC\\Core\\Controller\\AvatarController' => $baseDir . '/core/Controller/AvatarController.php', + 'OC\\Core\\Controller\\ClientFlowLoginController' => $baseDir . '/core/Controller/ClientFlowLoginController.php', 'OC\\Core\\Controller\\CssController' => $baseDir . '/core/Controller/CssController.php', 'OC\\Core\\Controller\\JsController' => $baseDir . '/core/Controller/JsController.php', 'OC\\Core\\Controller\\LoginController' => $baseDir . '/core/Controller/LoginController.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 82c31c24a2..5cb12a4b64 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -474,6 +474,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c 'OC\\Core\\Command\\User\\ResetPassword' => __DIR__ . '/../../..' . '/core/Command/User/ResetPassword.php', 'OC\\Core\\Command\\User\\Setting' => __DIR__ . '/../../..' . '/core/Command/User/Setting.php', 'OC\\Core\\Controller\\AvatarController' => __DIR__ . '/../../..' . '/core/Controller/AvatarController.php', + 'OC\\Core\\Controller\\ClientFlowLoginController' => __DIR__ . '/../../..' . '/core/Controller/ClientFlowLoginController.php', 'OC\\Core\\Controller\\CssController' => __DIR__ . '/../../..' . '/core/Controller/CssController.php', 'OC\\Core\\Controller\\JsController' => __DIR__ . '/../../..' . '/core/Controller/JsController.php', 'OC\\Core\\Controller\\LoginController' => __DIR__ . '/../../..' . '/core/Controller/LoginController.php', diff --git a/tests/Core/Controller/ClientFlowLoginControllerTest.php b/tests/Core/Controller/ClientFlowLoginControllerTest.php new file mode 100644 index 0000000000..7c525b5321 --- /dev/null +++ b/tests/Core/Controller/ClientFlowLoginControllerTest.php @@ -0,0 +1,408 @@ + + * + * @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 Tests\Core\Controller; + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Exceptions\PasswordlessTokenException; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OC\Core\Controller\ClientFlowLoginController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Defaults; +use OCP\IL10N; +use OCP\IRequest; +use OCP\ISession; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use OCP\Session\Exceptions\SessionNotAvailableException; +use Test\TestCase; + +class ClientFlowLoginControllerTest extends TestCase { + /** @var IRequest|\PHPUnit_Framework_MockObject_MockObject */ + private $request; + /** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */ + private $userSession; + /** @var IL10N|\PHPUnit_Framework_MockObject_MockObject */ + private $l10n; + /** @var Defaults|\PHPUnit_Framework_MockObject_MockObject */ + private $defaults; + /** @var ISession|\PHPUnit_Framework_MockObject_MockObject */ + private $session; + /** @var IProvider|\PHPUnit_Framework_MockObject_MockObject */ + private $tokenProvider; + /** @var ISecureRandom|\PHPUnit_Framework_MockObject_MockObject */ + private $random; + /** @var IURLGenerator|\PHPUnit_Framework_MockObject_MockObject */ + private $urlGenerator; + /** @var ClientFlowLoginController */ + private $clientFlowLoginController; + + public function setUp() { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->l10n = $this->createMock(IL10N::class); + $this->l10n + ->expects($this->any()) + ->method('t') + ->will($this->returnCallback(function($text, $parameters = array()) { + return vsprintf($text, $parameters); + })); + $this->defaults = $this->createMock(Defaults::class); + $this->session = $this->createMock(ISession::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->random = $this->createMock(ISecureRandom::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + + $this->clientFlowLoginController = new ClientFlowLoginController( + 'core', + $this->request, + $this->userSession, + $this->l10n, + $this->defaults, + $this->session, + $this->tokenProvider, + $this->random, + $this->urlGenerator + ); + } + + public function testShowAuthPickerPageNotAuthenticated() { + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(true); + + $expected = new TemplateResponse( + 'core', + '403', + [ + 'file' => 'Auth flow can only be started unauthenticated.', + ], + 'guest' + ); + $this->assertEquals($expected, $this->clientFlowLoginController->showAuthPickerPage()); + } + + public function testShowAuthPickerPage() { + $this->userSession + ->expects($this->once()) + ->method('isLoggedIn') + ->willReturn(false); + $this->random + ->expects($this->once()) + ->method('generate') + ->with( + 64, + ISecureRandom::CHAR_LOWER.ISecureRandom::CHAR_UPPER.ISecureRandom::CHAR_DIGITS + ) + ->willReturn('StateToken'); + $this->session + ->expects($this->once()) + ->method('set') + ->with('client.flow.state.token', 'StateToken'); + $this->request + ->expects($this->exactly(2)) + ->method('getHeader') + ->with('USER_AGENT') + ->willReturn('Mac OS X Sync Client'); + $this->defaults + ->expects($this->once()) + ->method('getName') + ->willReturn('ExampleCloud'); + $this->request + ->expects($this->once()) + ->method('getServerHost') + ->willReturn('example.com'); + + $expected = new TemplateResponse( + 'core', + 'loginflow/authpicker', + [ + 'client' => 'Mac OS X Sync Client', + 'instanceName' => 'ExampleCloud', + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => 'StateToken', + 'serverHost' => 'example.com', + ], + 'guest' + ); + $this->assertEquals($expected, $this->clientFlowLoginController->showAuthPickerPage()); + } + + public function testRedirectPageWithInvalidToken() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('OtherToken'); + + $expected = new TemplateResponse( + 'core', + '403', + [ + 'file' => 'State token does not match', + ], + 'guest' + ); + $expected->setStatus(Http::STATUS_FORBIDDEN); + $this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken')); + } + + public function testRedirectPageWithoutToken() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn(null); + + $expected = new TemplateResponse( + 'core', + '403', + [ + 'file' => 'State token does not match', + ], + 'guest' + ); + $expected->setStatus(Http::STATUS_FORBIDDEN); + $this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken')); + } + + public function testRedirectPage() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('MyStateToken'); + + $expected = new TemplateResponse( + 'core', + 'loginflow/redirect', + [ + 'urlGenerator' => $this->urlGenerator, + 'stateToken' => 'MyStateToken', + ], + 'empty' + ); + $this->assertEquals($expected, $this->clientFlowLoginController->redirectPage('MyStateToken')); + } + + public function testGenerateAppPasswordWithInvalidToken() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('OtherToken'); + $this->session + ->expects($this->once()) + ->method('remove') + ->with('client.flow.state.token'); + + $expected = new TemplateResponse( + 'core', + '403', + [ + 'file' => 'State token does not match', + ], + 'guest' + ); + $expected->setStatus(Http::STATUS_FORBIDDEN); + $this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken')); + } + + public function testGenerateAppPasswordWithSessionNotAvailableException() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('MyStateToken'); + $this->session + ->expects($this->once()) + ->method('remove') + ->with('client.flow.state.token'); + $this->session + ->expects($this->once()) + ->method('getId') + ->willThrowException(new SessionNotAvailableException()); + + $expected = new Http\Response(); + $expected->setStatus(Http::STATUS_FORBIDDEN); + $this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken')); + } + + public function testGenerateAppPasswordWithInvalidTokenException() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('MyStateToken'); + $this->session + ->expects($this->once()) + ->method('remove') + ->with('client.flow.state.token'); + $this->session + ->expects($this->once()) + ->method('getId') + ->willReturn('SessionId'); + $this->tokenProvider + ->expects($this->once()) + ->method('getToken') + ->with('SessionId') + ->willThrowException(new InvalidTokenException()); + + $expected = new Http\Response(); + $expected->setStatus(Http::STATUS_FORBIDDEN); + $this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken')); + } + + public function testGeneratePasswordWithPassword() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('MyStateToken'); + $this->session + ->expects($this->once()) + ->method('remove') + ->with('client.flow.state.token'); + $this->session + ->expects($this->once()) + ->method('getId') + ->willReturn('SessionId'); + $myToken = $this->createMock(IToken::class); + $myToken + ->expects($this->once()) + ->method('getLoginName') + ->willReturn('MyLoginName'); + $this->tokenProvider + ->expects($this->once()) + ->method('getToken') + ->with('SessionId') + ->willReturn($myToken); + $this->tokenProvider + ->expects($this->once()) + ->method('getPassword') + ->with($myToken, 'SessionId') + ->willReturn('MyPassword'); + $this->random + ->expects($this->once()) + ->method('generate') + ->with(72) + ->willReturn('MyGeneratedToken'); + $user = $this->createMock(IUser::class); + $user + ->expects($this->once()) + ->method('getUID') + ->willReturn('MyUid'); + $this->userSession + ->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->tokenProvider + ->expects($this->once()) + ->method('generateToken') + ->with( + 'MyGeneratedToken', + 'MyUid', + 'MyLoginName', + 'MyPassword', + 'unknown', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + $this->request + ->expects($this->once()) + ->method('getServerHost') + ->willReturn('example.com'); + + $expected = new Http\RedirectResponse('nc://MyLoginName:MyGeneratedToken@example.com'); + $this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken')); + } + + public function testGeneratePasswordWithoutPassword() { + $this->session + ->expects($this->once()) + ->method('get') + ->with('client.flow.state.token') + ->willReturn('MyStateToken'); + $this->session + ->expects($this->once()) + ->method('remove') + ->with('client.flow.state.token'); + $this->session + ->expects($this->once()) + ->method('getId') + ->willReturn('SessionId'); + $myToken = $this->createMock(IToken::class); + $myToken + ->expects($this->once()) + ->method('getLoginName') + ->willReturn('MyLoginName'); + $this->tokenProvider + ->expects($this->once()) + ->method('getToken') + ->with('SessionId') + ->willReturn($myToken); + $this->tokenProvider + ->expects($this->once()) + ->method('getPassword') + ->with($myToken, 'SessionId') + ->willThrowException(new PasswordlessTokenException()); + $this->random + ->expects($this->once()) + ->method('generate') + ->with(72) + ->willReturn('MyGeneratedToken'); + $user = $this->createMock(IUser::class); + $user + ->expects($this->once()) + ->method('getUID') + ->willReturn('MyUid'); + $this->userSession + ->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->tokenProvider + ->expects($this->once()) + ->method('generateToken') + ->with( + 'MyGeneratedToken', + 'MyUid', + 'MyLoginName', + null, + 'unknown', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + $this->request + ->expects($this->once()) + ->method('getServerHost') + ->willReturn('example.com'); + + $expected = new Http\RedirectResponse('nc://MyLoginName:MyGeneratedToken@example.com'); + $this->assertEquals($expected, $this->clientFlowLoginController->generateAppPassword('MyStateToken')); + } +}