Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
'OCA\\Text\\DirectEditing\\TextDocumentCreator' => $baseDir . '/../lib/DirectEditing/TextDocumentCreator.php',
'OCA\\Text\\Event\\LoadEditor' => $baseDir . '/../lib/Event/LoadEditor.php',
'OCA\\Text\\Event\\MentionEvent' => $baseDir . '/../lib/Event/MentionEvent.php',
'OCA\\Text\\Exception\\AccountDisabledException' => $baseDir . '/../lib/Exception/AccountDisabledException.php',
'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => $baseDir . '/../lib/Exception/DocumentHasUnsavedChangesException.php',
'OCA\\Text\\Exception\\DocumentSaveConflictException' => $baseDir . '/../lib/Exception/DocumentSaveConflictException.php',
'OCA\\Text\\Exception\\InvalidDocumentBaseVersionEtagException' => $baseDir . '/../lib/Exception/InvalidDocumentBaseVersionEtagException.php',
Expand Down
1 change: 1 addition & 0 deletions composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class ComposerStaticInitText
'OCA\\Text\\DirectEditing\\TextDocumentCreator' => __DIR__ . '/..' . '/../lib/DirectEditing/TextDocumentCreator.php',
'OCA\\Text\\Event\\LoadEditor' => __DIR__ . '/..' . '/../lib/Event/LoadEditor.php',
'OCA\\Text\\Event\\MentionEvent' => __DIR__ . '/..' . '/../lib/Event/MentionEvent.php',
'OCA\\Text\\Exception\\AccountDisabledException' => __DIR__ . '/..' . '/../lib/Exception/AccountDisabledException.php',
'OCA\\Text\\Exception\\DocumentHasUnsavedChangesException' => __DIR__ . '/..' . '/../lib/Exception/DocumentHasUnsavedChangesException.php',
'OCA\\Text\\Exception\\DocumentSaveConflictException' => __DIR__ . '/..' . '/../lib/Exception/DocumentSaveConflictException.php',
'OCA\\Text\\Exception\\InvalidDocumentBaseVersionEtagException' => __DIR__ . '/..' . '/../lib/Exception/InvalidDocumentBaseVersionEtagException.php',
Expand Down
14 changes: 14 additions & 0 deletions lib/Exception/AccountDisabledException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Text\Exception;

class AccountDisabledException extends \Exception {

}
16 changes: 16 additions & 0 deletions lib/Middleware/SessionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use OC\User\NoUserException;
use OCA\Text\Controller\ISessionAwareController;
use OCA\Text\Exception\AccountDisabledException;
use OCA\Text\Exception\InvalidDocumentBaseVersionEtagException;
use OCA\Text\Exception\InvalidSessionException;
use OCA\Text\Middleware\Attribute\RequireDocumentBaseVersionEtag;
Expand All @@ -29,6 +30,7 @@
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as ShareManager;
Expand All @@ -45,13 +47,15 @@ public function __construct(
private IRootFolder $rootFolder,
private ShareManager $shareManager,
private IL10N $l10n,
private IUserManager $userManager,
) {
}

/**
* @throws ReflectionException
* @throws InvalidDocumentBaseVersionEtagException
* @throws InvalidSessionException
* @throws AccountDisabledException
*/
public function beforeController(Controller $controller, string $methodName): void {
if (!$controller instanceof ISessionAwareController) {
Expand Down Expand Up @@ -92,6 +96,7 @@ private function assertDocumentBaseVersionEtag(): void {

/**
* @throws InvalidSessionException
* @throws AccountDisabledException
*/
private function assertDocumentSession(ISessionAwareController $controller): void {
$documentId = (int)$this->request->getParam('documentId');
Expand All @@ -104,6 +109,13 @@ private function assertDocumentSession(ISessionAwareController $controller): voi
throw new InvalidSessionException();
}

if (!$session->isGuest()) {
$user = $this->userManager->get($session->getUserId());
if ($user === null || !$user->isEnabled()) {
throw new AccountDisabledException();
}
}

$document = $this->documentService->getDocument($documentId);
if (!$document) {
throw new InvalidSessionException();
Expand Down Expand Up @@ -187,6 +199,10 @@ public function afterException($controller, $methodName, \Exception $exception):
return new JSONResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED);
}

if ($exception instanceof AccountDisabledException) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}

if ($exception instanceof InvalidSessionException) {
return new JSONResponse([], 403);
}
Expand Down
101 changes: 99 additions & 2 deletions tests/unit/Middleware/SessionMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
namespace OCA\Text\Tests;

use OCA\Text\Controller\ISessionAwareController;
use OCA\Text\Db\Document;
use OCA\Text\Db\Session;
use OCA\Text\Exception\AccountDisabledException;
use OCA\Text\Exception\InvalidSessionException;
use OCA\Text\Middleware\SessionMiddleware;
use OCA\Text\Service\DocumentService;
use OCA\Text\Service\SessionService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Constants;
use OCP\Files\File;
use OCP\Files\Folder;
Expand All @@ -15,6 +20,7 @@
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager;
Expand All @@ -28,6 +34,9 @@ class SessionMiddlewareTest extends TestCase {
private IUserSession $userSession;
private IRootFolder $rootFolder;
private IManager $shareManager;
private SessionService $sessionService;
private DocumentService $documentService;
private IUserManager $userManager;

protected function setUp(): void {
parent::setUp();
Expand All @@ -37,16 +46,20 @@ protected function setUp(): void {
$this->userSession = $this->createMock(IUserSession::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$this->shareManager = $this->createMock(IManager::class);
$this->sessionService = $this->createMock(SessionService::class);
$this->documentService = $this->createMock(DocumentService::class);
$this->userManager = $this->createMock(IUserManager::class);

$this->middleware = new SessionMiddleware(
$this->request,
$this->createMock(SessionService::class),
$this->createMock(DocumentService::class),
$this->sessionService,
$this->documentService,
$this->session,
$this->userSession,
$this->rootFolder,
$this->shareManager,
$this->createMock(IL10N::class),
$this->userManager,
);
}

Expand Down Expand Up @@ -138,6 +151,90 @@ public function testLoggedInUserWithValidTokenUnauthenticated(): void {
$this->invokeMiddleware($share, $user);
}

public function testDocumentSessionWithEnabledUserAllowed(): void {
$session = new Session();
$session->setUserId('alice');

$user = $this->createMock(IUser::class);
$user->method('isEnabled')->willReturn(true);

$this->sessionService->method('getValidSession')->willReturn($session);
$this->userManager->method('get')->with('alice')->willReturn($user);
$this->documentService->method('getDocument')->willReturn($this->createMock(Document::class));

$controller = $this->createMock(ISessionAwareController::class);
$controller->expects($this->once())->method('setUserId')->with('alice');

$this->invokeAssertDocumentSession($controller);
$this->assertTrue(true);
}

public function testDocumentSessionWithDisabledUserBlocked(): void {
$this->expectException(AccountDisabledException::class);

$session = new Session();
$session->setUserId('alice');

$user = $this->createMock(IUser::class);
$user->method('isEnabled')->willReturn(false);

$this->sessionService->method('getValidSession')->willReturn($session);
$this->userManager->method('get')->with('alice')->willReturn($user);

$controller = $this->createMock(ISessionAwareController::class);
$controller->expects($this->never())->method('setUserId');

$this->invokeAssertDocumentSession($controller);
}

public function testDocumentSessionWithNonexistentUserBlocked(): void {
$this->expectException(AccountDisabledException::class);

$session = new Session();
$session->setUserId('alice');

$this->sessionService->method('getValidSession')->willReturn($session);
$this->userManager->method('get')->with('alice')->willReturn(null);

$controller = $this->createMock(ISessionAwareController::class);
$controller->expects($this->never())->method('setUserId');

$this->invokeAssertDocumentSession($controller);
}

public function testDocumentSessionGuestSessionSkipsUserCheck(): void {
$session = new Session();

$this->sessionService->method('getValidSession')->willReturn($session);
$this->userManager->expects($this->never())->method('get');
$this->documentService->method('getDocument')->willReturn($this->createMock(Document::class));

$controller = $this->createMock(ISessionAwareController::class);

$this->invokeAssertDocumentSession($controller, 'shareToken123');
$this->assertTrue(true);
}

public function testAfterExceptionMapsAccountDisabledToForbidden(): void {
$controller = $this->createMock(ISessionAwareController::class);

$response = $this->middleware->afterException($controller, 'push', new AccountDisabledException());

$this->assertInstanceOf(JSONResponse::class, $response);
$this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus());
}

private function invokeAssertDocumentSession(ISessionAwareController $controller, ?string $shareToken = null): void {
$this->request->method('getParam')->willReturnMap([
['documentId', null, 999],
['sessionId', null, 1],
['sessionToken', null, 'sessionToken'],
['token', null, $shareToken],
]);

self::invokePrivate($this->middleware, 'assertDocumentSession', [$controller]);
}

private function createPasswordProtectedShare(string $id): IShare {
$share = $this->createMock(IShare::class);
$share->method('getId')->willReturn($id);
Expand Down
Loading