nextcloud/core/Controller/ClientFlowLoginV2Controller.php
Louis Chemineau 242164f0fd
feat: Close sessions created for login flow v2
Sessions created during the login flow v2 should be short lived to not leave an unexpected opened session in the browser.

This commit add a property to the session object to track its origin, and will close it as soon as possible, i.e., on the first non public page request.

Signed-off-by: Louis Chemineau <louis@chmn.me>
2025-03-03 12:07:32 +01:00

377 lines
10 KiB
PHP

<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @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\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\Attribute\UseSession;
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\IUser;
use OCP\IUserSession;
use OCP\Security\ISecureRandom;
class ClientFlowLoginV2Controller extends Controller {
public const TOKEN_NAME = 'client.flow.v2.login.token';
public const STATE_NAME = 'client.flow.v2.state.token';
// Denotes that the session was created for the login flow and should therefore be ephemeral.
public const EPHEMERAL_NAME = 'client.flow.v2.state.ephemeral';
private LoginFlowV2Service $loginFlowV2Service;
private IURLGenerator $urlGenerator;
private IUserSession $userSession;
private ISession $session;
private ISecureRandom $random;
private Defaults $defaults;
private ?string $userId;
private IL10N $l10n;
public function __construct(string $appName,
IRequest $request,
LoginFlowV2Service $loginFlowV2Service,
IURLGenerator $urlGenerator,
ISession $session,
IUserSession $userSession,
ISecureRandom $random,
Defaults $defaults,
?string $userId,
IL10N $l10n) {
parent::__construct($appName, $request);
$this->loginFlowV2Service = $loginFlowV2Service;
$this->urlGenerator = $urlGenerator;
$this->session = $session;
$this->userSession = $userSession;
$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, $user = ''): Response {
if (!$this->loginFlowV2Service->startLoginFlow($token)) {
return $this->loginTokenForbiddenResponse();
}
$this->session->set(self::TOKEN_NAME, $token);
return new RedirectResponse(
$this->urlGenerator->linkToRouteAbsolute('core.ClientFlowLoginV2.showAuthPickerPage', ['user' => $user])
);
}
/**
* @NoCSRFRequired
* @PublicPage
*/
#[UseSession]
public function showAuthPickerPage($user = ''): 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::STATE_NAME, $stateToken);
return new StandaloneTemplateResponse(
$this->appName,
'loginflowv2/authpicker',
[
'client' => $flow->getClientName(),
'instanceName' => $this->defaults->getName(),
'urlGenerator' => $this->urlGenerator,
'stateToken' => $stateToken,
'user' => $user,
],
'guest'
);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
* @NoSameSiteCookieRequired
*/
#[UseSession]
public function grantPage(?string $stateToken): StandaloneTemplateResponse {
if ($stateToken === null) {
return $this->stateTokenMissingResponse();
}
if (!$this->isValidStateToken($stateToken)) {
return $this->stateTokenForbiddenResponse();
}
try {
$flow = $this->getFlowByLoginToken();
} catch (LoginFlowV2NotFoundException $e) {
return $this->loginTokenForbiddenResponse();
}
/** @var IUser $user */
$user = $this->userSession->getUser();
return new StandaloneTemplateResponse(
$this->appName,
'loginflowv2/grant',
[
'userId' => $user->getUID(),
'userDisplayName' => $user->getDisplayName(),
'client' => $flow->getClientName(),
'instanceName' => $this->defaults->getName(),
'urlGenerator' => $this->urlGenerator,
'stateToken' => $stateToken,
],
'guest'
);
}
/**
* @PublicPage
*/
public function apptokenRedirect(?string $stateToken, string $user, string $password) {
if ($stateToken === null) {
return $this->stateTokenMissingResponse();
}
if (!$this->isValidStateToken($stateToken)) {
return $this->stateTokenForbiddenResponse();
}
try {
$this->getFlowByLoginToken();
} catch (LoginFlowV2NotFoundException $e) {
return $this->loginTokenForbiddenResponse();
}
$loginToken = $this->session->get(self::TOKEN_NAME);
// Clear session variables
$this->session->remove(self::TOKEN_NAME);
$this->session->remove(self::STATE_NAME);
try {
$token = \OC::$server->get(\OC\Authentication\Token\IProvider::class)->getToken($password);
if ($token->getLoginName() !== $user) {
throw new InvalidTokenException('login name does not match');
}
} catch (InvalidTokenException $e) {
$response = new StandaloneTemplateResponse(
$this->appName,
'403',
[
'message' => $this->l10n->t('Invalid app password'),
],
'guest'
);
$response->setStatus(Http::STATUS_FORBIDDEN);
return $response;
}
$result = $this->loginFlowV2Service->flowDoneWithAppPassword($loginToken, $this->getServerPath(), $token->getLoginName(), $password);
return $this->handleFlowDone($result);
}
/**
* @NoAdminRequired
*/
#[UseSession]
public function generateAppPassword(?string $stateToken): Response {
if ($stateToken === null) {
return $this->stateTokenMissingResponse();
}
if (!$this->isValidStateToken($stateToken)) {
return $this->stateTokenForbiddenResponse();
}
try {
$this->getFlowByLoginToken();
} catch (LoginFlowV2NotFoundException $e) {
return $this->loginTokenForbiddenResponse();
}
$loginToken = $this->session->get(self::TOKEN_NAME);
// Clear session variables
$this->session->remove(self::TOKEN_NAME);
$this->session->remove(self::STATE_NAME);
$sessionId = $this->session->getId();
$result = $this->loginFlowV2Service->flowDone($loginToken, $sessionId, $this->getServerPath(), $this->userId);
return $this->handleFlowDone($result);
}
private function handleFlowDone(bool $result): StandaloneTemplateResponse {
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::STATE_NAME);
if (!is_string($stateToken) || !is_string($currentToken)) {
return false;
}
return hash_equals($currentToken, $stateToken);
}
private function stateTokenMissingResponse(): StandaloneTemplateResponse {
$response = new StandaloneTemplateResponse(
$this->appName,
'403',
[
'message' => $this->l10n->t('State token missing'),
],
'guest'
);
$response->setStatus(Http::STATUS_FORBIDDEN);
return $response;
}
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::TOKEN_NAME);
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'));
} elseif (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;
}
}