Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
use NCU\Security\Signature\IIncomingSignedRequest;
use NCU\Security\Signature\ISignatureManager;
use OC\Authentication\Token\PublicKeyTokenProvider;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
Expand Down Expand Up @@ -44,6 +45,7 @@
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\Server;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -92,7 +94,7 @@ public function __construct(
* @param string|null $ownerDisplayName Display name of the user who shared the item
* @param string|null $sharedBy Provider specific UID of the user who shared the resource
* @param string|null $sharedByDisplayName Display name of the user who shared the resource
* @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]]
* @param array{name: string, options?: array<string, mixed>, webdav?: array<string, mixed>} $protocol Old format: ['name' => 'webdav', 'options' => ['sharedSecret' => '...', 'permissions' => '...']] or New format: ['name' => 'webdav', 'webdav' => ['uri' => '...', 'sharedSecret' => '...', 'permissions' => [...]]]
* @param string $shareType 'group' or 'user' share
* @param string $resourceType 'file', 'calendar',...
*
Expand Down Expand Up @@ -127,9 +129,6 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
|| $shareType === null
|| !is_array($protocol)
|| !isset($protocol['name'])
|| !isset($protocol['options'])
|| !is_array($protocol['options'])
|| !isset($protocol['options']['sharedSecret'])
) {
return new JSONResponse(
[
Expand All @@ -140,6 +139,20 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
);
}

$protocolName = $protocol['name'];
$hasOldFormat = isset($protocol['options']) && is_array($protocol['options']) && isset($protocol['options']['sharedSecret']);
$hasNewFormat = isset($protocol[$protocolName]) && is_array($protocol[$protocolName]) && isset($protocol[$protocolName]['sharedSecret']);

if (!$hasOldFormat && !$hasNewFormat) {
return new JSONResponse(
[
'message' => 'Missing sharedSecret in protocol',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}

$supportedShareTypes = $this->config->getSupportedShareTypes($resourceType);
if (!in_array($shareType, $supportedShareTypes)) {
return new JSONResponse(
Expand Down Expand Up @@ -522,6 +535,12 @@ private function confirmNotificationIdentity(
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
if ($provider instanceof ISignedCloudFederationProvider) {
$identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification);
if ($identity === "") {
$tokenProvider = Server::get(PublicKeyTokenProvider::class);
$accessTokenDb = $tokenProvider->getToken($sharedSecret);
$refreshToken = $accessTokenDb->getUID();
$identity = $provider->getFederationIdFromSharedSecret($refreshToken, $notification);
}
} else {
$this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]);
return;
Expand Down
11 changes: 9 additions & 2 deletions apps/dav/appinfo/v1/publicwebdav.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use OC\Files\Storage\Wrapper\PermissionsMask;
use OC\Files\View;
use OCA\DAV\Connector\LegacyPublicAuth;
use OCA\DAV\Connector\Sabre\BearerAuth;
use OCA\DAV\Connector\Sabre\ServerFactory;
use OCA\DAV\Files\Sharing\FilesDropPlugin;
use OCA\DAV\Files\Sharing\PublicLinkCheckPlugin;
Expand Down Expand Up @@ -45,7 +46,9 @@
Server::get(ISession::class),
Server::get(IThrottler::class)
);
$bearerAuthBackend = Server::get(BearerAuth::class);
$authPlugin = new \Sabre\DAV\Auth\Plugin($authBackend);
$authPlugin->addBackend($bearerAuthBackend);

/** @var IEventDispatcher $eventDispatcher */
$eventDispatcher = Server::get(IEventDispatcher::class);
Expand Down Expand Up @@ -75,6 +78,7 @@
$authPlugin,
function (\Sabre\DAV\Server $server) use (
$authBackend,
$bearerAuthBackend,
$linkCheckPlugin,
$filesDropPlugin
) {
Expand All @@ -85,8 +89,11 @@ function (\Sabre\DAV\Server $server) use (
// this is what is thrown when trying to access a non-existing share
throw new \Sabre\DAV\Exception\NotAuthenticated();
}

$share = $authBackend->getShare();
try {
$share = $authBackend->getShare();
} catch (AssertionError $e) {
$share = $bearerAuthBackend->getShare();
}
$owner = $share->getShareOwner();
$isReadable = $share->getPermissions() & Constants::PERMISSION_READ;
$fileId = $share->getNodeId();
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',
'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php',
'OCA\\DAV\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php',
'OCA\\DAV\\Controller\\UpcomingEventsController' => $baseDir . '/../lib/Controller/UpcomingEventsController.php',
'OCA\\DAV\\DAV\\CustomPropertiesBackend' => $baseDir . '/../lib/DAV/CustomPropertiesBackend.php',
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => $baseDir . '/../lib/DAV/GroupPrincipalBackend.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',
'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php',
'OCA\\DAV\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php',
'OCA\\DAV\\Controller\\UpcomingEventsController' => __DIR__ . '/..' . '/../lib/Controller/UpcomingEventsController.php',
'OCA\\DAV\\DAV\\CustomPropertiesBackend' => __DIR__ . '/..' . '/../lib/DAV/CustomPropertiesBackend.php',
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/GroupPrincipalBackend.php',
Expand Down
15 changes: 15 additions & 0 deletions apps/dav/lib/Connector/Sabre/BearerAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
use OCP\IRequest;
use OCP\ISession;
use OCP\IUserSession;
use OCP\Server;
use OCP\Share\IManager;
use OCP\Share\IShare;
use Sabre\DAV\Auth\Backend\AbstractBearer;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
Expand All @@ -23,6 +26,7 @@ public function __construct(
private IRequest $request,
private IConfig $config,
private string $principalPrefix = 'principals/users/',
private string $token = '',
) {
// setup realm
$defaults = new Defaults();
Expand All @@ -40,17 +44,28 @@ private function setupUserFs($userId) {
*/
public function validateBearerToken($bearerToken) {
\OC_Util::setupFS();
$this->token = $bearerToken;

if (!$this->userSession->isLoggedIn()) {
$this->userSession->tryTokenLogin($this->request);
}
if (!$this->userSession->isLoggedIn()) {
$this->userSession->doTryTokenLogin($bearerToken);
}
if ($this->userSession->isLoggedIn()) {
return $this->setupUserFs($this->userSession->getUser()->getUID());
}

return false;
}

public function getShare(): IShare {
$shareManager = Server::get(IManager::class);
$share = $shareManager->getShareByToken($this->token);
assert($share !== null);
return $share;
}

/**
* \Sabre\DAV\Auth\Backend\AbstractBearer::challenge sets an WWW-Authenticate
* header which some DAV clients can't handle. Thus we override this function
Expand Down
198 changes: 198 additions & 0 deletions apps/dav/lib/Controller/TokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php

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

namespace OCA\DAV\Controller;

use OC\Authentication\Token\IProvider;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Exceptions\ExpiredTokenException;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\Authentication\Token\IToken;
use OCP\IRequest;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
use OCP\IAppConfig;
use OC\OCM\OCMSignatoryManager;
use NCU\Security\Signature\ISignatureManager;
use NCU\Security\Signature\Exceptions\SignatureNotFoundException;
use NCU\Security\Signature\Exceptions\SignatureException;
use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
use NCU\Security\Signature\Model\IIncomingSignedRequest;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use OC\Security\Signature\Model\IncomingSignedRequest;

/**
* Controller for the /token endpoint
* Exchanges long-lived refresh tokens for short-lived access tokens
*
* @since 32.0.0
*/
class TokenController extends ApiController {
public function __construct(
IRequest $request,
private readonly IProvider $tokenProvider,
private readonly ISecureRandom $random,
private readonly ITimeFactory $timeFactory,
private readonly LoggerInterface $logger,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private readonly IAppConfig $appConfig,
) {
parent::__construct('dav', $request);
}

/**
* Verify the signature of incoming request if available
*
* @return IncomingSignedRequest|null null if remote does not support signed requests
* @throws IncomingRequestException if signature is required but invalid
*/
private function verifySignedRequest(): ?IncomingSignedRequest {
try {
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
$this->logger->debug('Token request signature verified', [
'origin' => $signedRequest->getOrigin()
]);
return $signedRequest;
} catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
$this->logger->debug('Token request not signed', ['exception' => $e]);

if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) {
$this->logger->notice('Rejected unsigned token request', ['exception' => $e]);
throw new IncomingRequestException('Unsigned request not allowed');
}
return null;
} catch (SignatureException $e) {
$this->logger->warning('Invalid token request signature', ['exception' => $e]);
throw new IncomingRequestException('Invalid signature');
}
}

/**
* Exchange a refresh token for a short-lived access token
*
* @return DataResponse<Http::STATUS_OK, array{access_token: string, token_type: string, expires_in: int}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Access token successfully generated
* 400: Bad request - missing refresh token or invalid request format
* 401: Unauthorized - invalid or expired refresh token, or invalid signature
*/
#[PublicPage]
#[NoCSRFRequired]
#[FrontpageRoute(verb: 'POST', url: '/api/v1/access-token')]
public function accessToken(): DataResponse {
try {
$signedRequest = $this->verifySignedRequest();
} catch (IncomingRequestException $e) {
$this->logger->warning('Token request signature verification failed', [
'exception' => $e
]);
return new DataResponse(
['error' => 'invalid_request'],
Http::STATUS_UNAUTHORIZED
);
}

$body = file_get_contents('php://input');
$data = json_decode($body, true);

if (!is_array($data)) {
return new DataResponse(
['error' => 'invalid_request'],
Http::STATUS_BAD_REQUEST
);
}

$refreshToken = $data['code'] ?? '';
$grantType = $data['grant_type'] ?? '';

if ($grantType !== 'authorization_code') {
return new DataResponse(
['error' => 'unsupported_grant_type'],
Http::STATUS_BAD_REQUEST
);
}

if (empty($refreshToken)) {
return new DataResponse(
['error' => 'refresh_token is required'],
Http::STATUS_BAD_REQUEST
);
}

try {
$token = $this->tokenProvider->getToken($refreshToken);

if ($token->getType() !== IToken::PERMANENT_TOKEN) {
$this->logger->warning('Attempted to use non-permanent token as refresh token', [
'tokenId' => $token->getId(),
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
}

$accessTokenString = $this->random->generate(
72,
ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS
);

$expiresIn = 3600; // 1 hour in seconds
$expiresAt = $this->timeFactory->getTime() + $expiresIn;

$accessToken = $this->tokenProvider->generateToken(
$accessTokenString,
$refreshToken, // Keep refresh token with access token as UID
$token->getLoginName(),
null, // No password for access tokens
'OCM Access Token',
IToken::TEMPORARY_TOKEN,
IToken::DO_NOT_REMEMBER
);

$accessToken->setExpires($expiresAt);
$this->tokenProvider->updateToken($accessToken);

return new DataResponse([
'access_token' => $accessTokenString,
'token_type' => 'Bearer',
'expires_in' => $expiresIn,
], Http::STATUS_OK);
} catch (InvalidTokenException $e) {
$this->logger->info('Invalid refresh token provided', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
} catch (ExpiredTokenException $e) {
$this->logger->info('Expired refresh token provided', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'invalid_grant'],
Http::STATUS_UNAUTHORIZED
);
} catch (\Exception $e) {
$this->logger->error('Error generating access token', [
'exception' => $e,
]);
return new DataResponse(
['error' => 'server_error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
}
Loading