Add new auth flow

This implements the basics for the new app-password based authentication flow for our clients.
The current implementation tries to keep it as simple as possible and works the following way:

1. Unauthenticated client opens `/index.php/login/flow`
2. User will be asked whether they want to grant access to the client
3. If accepted the user has the chance to do so using existing App Token or automatically generate an app password.

If the user chooses to use an existing app token then that one will simply be redirected to the `nc://` protocol handler.
While we can improve on that in the future, I think keeping this smaller at the moment has its advantages. Also, in the
near future we have to think about an automatic migration endpoint so there's that anyways :-)

If the user chooses to use the regular login the following happens:

1. A session state token is written to the session
2. User is redirected to the login page
3. If successfully authenticated they will be redirected to a page redirecting to the POST controller
4. The POST controller will check if the CSRF token as well as the state token is correct, if yes the user will be redirected to the `nc://` protocol handler.

This approach is quite simple but also allows to be extended in the future. One could for example allow external websites to consume this authentication endpoint as well.

Signed-off-by: Lukas Reschke <lukas@statuscode.ch>
This commit is contained in:
Lukas Reschke 2017-04-24 21:11:48 +02:00 committed by Roeland Jago Douma
parent db94b5d4af
commit 6a16df7288
No known key found for this signature in database
GPG Key ID: F941078878347C0C
10 changed files with 761 additions and 0 deletions

View File

@ -0,0 +1,236 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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) {
$this->session->remove(self::stateName);
if(!$this->isValidToken($stateToken)) {
return $this->stateTokenForbiddenResponse();
}
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(60);
$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());
}
}

View File

@ -0,0 +1,5 @@
.picker-window {
background: rgba(255,255,255,0.3);
border-radius: 3px;
margin-bottom:20px;
}

View File

@ -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());
});
});

View File

@ -0,0 +1,3 @@
jQuery(document).ready(function() {
$('#submit-redirect-form').trigger('click');
});

View File

@ -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'],

View File

@ -0,0 +1,57 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
script('core', 'login/authpicker');
style('core', 'login/authpicker');
/** @var array $_ */
/** @var \OCP\IURLGenerator $urlGenerator */
$urlGenerator = $_['urlGenerator'];
?>
<div class="picker-window">
<p class="info">
<?php p($l->t('You are about to grant "%s" access to your %s account.', [$_['client'], $_['instanceName']])) ?>
</p>
<br/>
<p id="redirect-link">
<a href="<?php p($urlGenerator->linkToRouteAbsolute('core.ClientFlowLogin.redirectPage', ['stateToken' => $_['stateToken']])) ?>">
<input type="submit" class="login primary icon-confirm-white" value="<?php p('Grant access') ?>">
</a>
</p>
<fieldset id="app-token-login-field" class="hidden">
<p class="grouptop">
<input type="text" name="user" id="user" placeholder="<?php p($l->t('Username')) ?>">
<label for="user" class="infield"><?php p($l->t('Username')) ?></label>
</p>
<p class="groupbottom">
<input type="password" name="password" id="password" placeholder="<?php p($l->t('App token')) ?>">
<label for="password" class="infield"><?php p($l->t('Password')) ?></label>
</p>
<input type="hidden" id="serverHost" value="<?php p($_['serverHost']) ?>" />
<input id="submit-app-token-login" type="submit" class="login primary icon-confirm-white" value="<?php p('Grant access') ?>">
</fieldset>
</div>
<a id="app-token-login" class="warning" href="#"><?php p($l->t('Alternative login using app token')) ?></a>

View File

@ -0,0 +1,34 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
script('core', 'login/redirect');
/** @var array $_ */
/** @var \OCP\IURLGenerator $urlGenerator */
$urlGenerator = $_['urlGenerator'];
?>
<?php p($l->t('Redirecting…')) ?>
<form method="POST" action="<?php p($urlGenerator->linkToRouteAbsolute('core.ClientFlowLogin.generateAppPassword')) ?>">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']) ?>" />
<input type="hidden" name="statetoken" value="<?php p($_['stateToken']) ?>" />
<input id="submit-redirect-form" type="submit" class="hidden "/>
</form>

View File

@ -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',

View File

@ -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',

View File

@ -0,0 +1,408 @@
<?php
/**
* @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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(60)
->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(60)
->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'));
}
}