From dbe13f5d7dd97fa9e16e2e8253abfcd8aa827712 Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Wed, 25 Mar 2026 19:53:25 +0100 Subject: [PATCH] feat: add HTTP Digest authentication support for WebDAV backend Implements the feature requested in PR #225 / proposed in PR #227. Adds an optional second constructor argument ('basic' or 'digest', defaulting to 'basic' for full backward compatibility). AI-assisted-by: Claude Sonnet 4.6 Signed-off-by: Anna Larch --- lib/WebDavAuth.php | 254 ++++++++++++++++++++++-- tests/unit/WebDavAuthTest.php | 350 ++++++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+), 17 deletions(-) create mode 100644 tests/unit/WebDavAuthTest.php diff --git a/lib/WebDavAuth.php b/lib/WebDavAuth.php index ab4cc72..0344d51 100644 --- a/lib/WebDavAuth.php +++ b/lib/WebDavAuth.php @@ -1,5 +1,7 @@ * This file is licensed under the Affero General Public License version 3 or @@ -9,44 +11,262 @@ namespace OCA\UserExternal; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + class WebDavAuth extends Base { - private $webDavAuthUrl; + private string $webDavAuthUrl; + private string $authType; - public function __construct($webDavAuthUrl) { - parent::__construct($webDavAuthUrl); + public function __construct( + string $webDavAuthUrl, + string $authType = 'basic', + ?IDBConnection $db = null, + ?IUserManager $userManager = null, + ?IGroupManager $groupManager = null, + ?LoggerInterface $logger = null, + ) { + parent::__construct($webDavAuthUrl, $db, $userManager, $groupManager, $logger); $this->webDavAuthUrl = $webDavAuthUrl; + $this->authType = $authType; } /** - * Check if the password is correct without logging in the user + * Check if the password is correct without logging in the user. * * @param string $uid The username * @param string $password The password - * - * @return true/false + * @return string|false The uid on success, false on failure */ public function checkPassword($uid, $password) { $uid = $this->resolveUid($uid); - $arr = explode('://', $this->webDavAuthUrl, 2); - if (! isset($arr) or count($arr) !== 2) { - $this->logger->error('ERROR: Invalid WebdavUrl: "' . $this->webDavAuthUrl . '" ', ['app' => 'user_external']); + $parsed = parse_url($this->webDavAuthUrl); + if ($parsed === false + || !isset($parsed['scheme'], $parsed['host']) + || !in_array($parsed['scheme'], ['http', 'https'], true) + || isset($parsed['user']) + ) { + $this->logger->error('Invalid WebDAV URL: "' . $this->webDavAuthUrl . '"', ['app' => 'user_external']); return false; } - [$protocol, $path] = $arr; - $url = $protocol . '://' . urlencode($uid) . ':' . urlencode($password) . '@' . $path; - $headers = get_headers($url); - if ($headers === false) { - $this->logger->error('ERROR: Not possible to connect to WebDAV Url: "' . $protocol . '://' . $path . '" ', ['app' => 'user_external']); + $url = $this->webDavAuthUrl; + + switch ($this->authType) { + case 'basic': + $responseHeaders = $this->fetchWithBasicAuth($url, $uid, $password); + break; + case 'digest': + $responseHeaders = $this->fetchWithDigestAuth($url, $uid, $password); + break; + default: + $this->logger->error( + 'Invalid WebDAV auth type: "' . $this->authType . '". Expected "basic" or "digest".', + ['app' => 'user_external'], + ); + return false; + } + + if ($responseHeaders === null) { return false; } - $returnCode = substr($headers[0], 9, 3); - if (substr($returnCode, 0, 1) === '2') { + $returnCode = substr($responseHeaders[0], 9, 3); + if (str_starts_with($returnCode, '2')) { $this->storeUser($uid); return $uid; + } + return false; + } + + /** + * Perform a HEAD request with HTTP Basic authentication. + * + * @return string[]|null Response headers, or null on connection failure. + */ + protected function fetchWithBasicAuth(string $url, string $uid, string $password): ?array { + $context = stream_context_create(['http' => [ + 'method' => 'HEAD', + 'header' => 'Authorization: Basic ' . base64_encode($uid . ':' . $password), + 'ignore_errors' => true, + 'follow_location' => 0, + ]]); + $responseHeaders = $this->fetchUrl($url, $context); + if ($responseHeaders === null) { + $this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + $returnCode = substr($responseHeaders[0], 9, 3); + if (str_starts_with($returnCode, '3')) { + $this->logger->error( + 'WebDAV URL returned a redirect (' . $returnCode . '). Redirects are not followed for authenticated requests to prevent credential leaking.', + ['app' => 'user_external'], + ); + return null; + } + + return $responseHeaders; + } + + /** + * Perform a two-step HEAD request with HTTP Digest authentication. + * + * @return string[]|null Response headers, or null on connection failure or missing challenge. + */ + protected function fetchWithDigestAuth(string $url, string $uid, string $password): ?array { + // Step 1: unauthenticated request to receive the server challenge + $challengeContext = stream_context_create(['http' => [ + 'method' => 'HEAD', + 'ignore_errors' => true, + 'follow_location' => 0, + ]]); + $challengeHeaders = $this->fetchUrl($url, $challengeContext); + if ($challengeHeaders === null) { + $this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + $challengeCode = substr($challengeHeaders[0], 9, 3); + if (str_starts_with($challengeCode, '3')) { + $this->logger->error( + 'WebDAV Digest challenge returned a redirect (' . $challengeCode . '). Redirects are not followed to prevent sending credentials to an unintended host.', + ['app' => 'user_external'], + ); + return null; + } + + // Step 2: find the WWW-Authenticate: Digest header + $authHeaderValue = null; + foreach ($challengeHeaders as $header) { + if (stripos($header, 'WWW-Authenticate: Digest ') === 0) { + $authHeaderValue = substr($header, strlen('WWW-Authenticate: Digest ')); + break; + } + } + + if ($authHeaderValue === null) { + $this->logger->error('No Digest challenge received from WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + // Step 3: parse the challenge parameters + $params = []; + preg_match_all('/(\w+)\s*=\s*(?:"([^"]*)"|([^\s,]+))/', $authHeaderValue, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $params[$m[1]] = $m[2] !== '' ? $m[2] : $m[3]; + } + + if (!isset($params['realm'], $params['nonce'])) { + $this->logger->error('Invalid Digest challenge from WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + $algorithm = $params['algorithm'] ?? 'MD5'; + if ($algorithm !== 'MD5') { + $this->logger->error( + 'Unsupported Digest algorithm: "' . $algorithm . '". Only MD5 is supported.', + ['app' => 'user_external'], + ); + return null; + } + + // Step 4: compute the digest response + $parsedUrl = parse_url($url); + $uri = $parsedUrl['path'] ?? '/'; + if (isset($parsedUrl['query'])) { + $uri .= '?' . $parsedUrl['query']; + } + + $qopTokens = isset($params['qop']) ? array_map('trim', explode(',', $params['qop'])) : []; + $useQop = in_array('auth', $qopTokens, true); + if (!empty($qopTokens) && !$useQop) { + $this->logger->error( + 'Unsupported Digest qop: "' . $params['qop'] . '". Only "auth" is supported.', + ['app' => 'user_external'], + ); + return null; + } + + try { + $A1 = md5($uid . ':' . $params['realm'] . ':' . $password); + $A2 = md5('HEAD:' . $uri); + + if ($useQop) { + $cnonce = bin2hex(random_bytes(8)); + $nc = '00000001'; + $response = md5($A1 . ':' . $params['nonce'] . ':' . $nc . ':' . $cnonce . ':auth:' . $A2); + } else { + $response = md5($A1 . ':' . $params['nonce'] . ':' . $A2); + } + } catch (\Throwable $e) { + $this->logger->error('Failed to compute Digest response: ' . $e->getMessage(), ['app' => 'user_external']); + return null; + } + + $digestHeader = sprintf( + 'Authorization: Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"', + $this->escapeDigestValue($uid), + $this->escapeDigestValue($params['realm']), + $this->escapeDigestValue($params['nonce']), + $this->escapeDigestValue($uri), + $response, + ); + if ($useQop) { + $digestHeader .= sprintf(', cnonce="%s", nc=%s, qop=auth', $cnonce, $nc); + } + if (isset($params['opaque'])) { + $digestHeader .= sprintf(', opaque="%s"', $this->escapeDigestValue($params['opaque'])); + } + + // Step 5: send the authenticated request + $context = stream_context_create(['http' => [ + 'method' => 'HEAD', + 'header' => $digestHeader, + 'ignore_errors' => true, + 'follow_location' => 0, + ]]); + $responseHeaders = $this->fetchUrl($url, $context); + if ($responseHeaders === null) { + $this->logger->error('Digest authenticated request failed for WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + $authCode = substr($responseHeaders[0], 9, 3); + if (str_starts_with($authCode, '3')) { + $this->logger->error( + 'WebDAV Digest authenticated request returned a redirect (' . $authCode . '). Redirects are not followed to prevent credential leaking.', + ['app' => 'user_external'], + ); + return null; + } + + return $responseHeaders; + } + + private function escapeDigestValue(string $value): string { + $value = str_replace(["\r", "\n"], '', $value); + return addcslashes($value, '"\\'); + } + + /** + * Perform an HTTP request and return the response headers. + * Extracted so tests can stub network calls without hitting the wire. + * + * @return string[]|null Response headers, or null if the server is unreachable. + */ + protected function fetchUrl(string $url, mixed $context = null): ?array { + $http_response_header = null; + if ($context !== null) { + $result = @file_get_contents($url, false, $context); } else { - return false; + $result = @file_get_contents($url); + } + if ($result === false && $http_response_header === null) { + return null; } + return $http_response_header; } } diff --git a/tests/unit/WebDavAuthTest.php b/tests/unit/WebDavAuthTest.php new file mode 100644 index 0000000..ac23bf0 --- /dev/null +++ b/tests/unit/WebDavAuthTest.php @@ -0,0 +1,350 @@ + */ + public array $fetchResponses = []; + /** @var list Captured stream contexts from fetchUrl() calls */ + public array $capturedContexts = []; + + protected function fetchUrl(string $url, mixed $context = null): ?array { + $this->capturedContexts[] = $context; + return array_shift($this->fetchResponses); + } +} + +class WebDavAuthTest extends TestCase { + private MockObject&IDBConnection $db; + private MockObject&IUserManager $userManager; + private MockObject&IGroupManager $groupManager; + private MockObject&LoggerInterface $logger; + + protected function setUp(): void { + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('escapeLikeParameter')->willReturnArgument(0); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + private function makeBackend(string $url = 'https://example.com/dav', string $authType = 'basic'): TestableWebDavAuth { + return new TestableWebDavAuth( + $url, + $authType, + $this->db, + $this->userManager, + $this->groupManager, + $this->logger, + ); + } + + private function mockQueryBuilder(int $existingUserCount = 0): MockObject&IQueryBuilder { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + $expr->method('iLike')->willReturn('1=1'); + + $queryFunction = $this->createMock(IQueryFunction::class); + $funcBuilder = $this->createMock(IFunctionBuilder::class); + $funcBuilder->method('count')->willReturn($queryFunction); + + $countResult = $this->createMock(IResult::class); + $countResult->method('fetchOne')->willReturn($existingUserCount); + $countResult->method('closeCursor')->willReturn(true); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('expr')->willReturn($expr); + $qb->method('func')->willReturn($funcBuilder); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('andWhere')->willReturnSelf(); + $qb->method('insert')->willReturnSelf(); + $qb->method('values')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturnArgument(0); + $qb->method('executeQuery')->willReturn($countResult); + + return $qb; + } + + // ------------------------------------------------------------------------- + // URL validation + // ------------------------------------------------------------------------- + + public function testInvalidUrlReturnsFalse(): void { + $backend = $this->makeBackend('not-a-valid-url'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testNonHttpSchemeReturnsFalse(): void { + $backend = $this->makeBackend('ftp://example.com/dav'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testUrlWithUserinfoReturnsFalse(): void { + $backend = $this->makeBackend('https://user:pass@example.com/dav'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + // ------------------------------------------------------------------------- + // Invalid auth type + // ------------------------------------------------------------------------- + + public function testInvalidAuthTypeReturnsFalseAndLogsError(): void { + $backend = $this->makeBackend('https://example.com/dav', 'kerberos'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + // ------------------------------------------------------------------------- + // Basic auth + // ------------------------------------------------------------------------- + + public function testBasicAuthSuccessStoresAndReturnsUid(): void { + $backend = $this->makeBackend(); + $backend->fetchResponses = [['HTTP/1.1 200 OK']]; + + $qb = $this->mockQueryBuilder(0); // new user + $qb->expects($this->once())->method('executeStatement'); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user', $backend->checkPassword('user', 'pass')); + } + + public function testBasicAuthSuccessDoesNotInsertExistingUser(): void { + $backend = $this->makeBackend(); + $backend->fetchResponses = [['HTTP/1.1 200 OK']]; + + $qb = $this->mockQueryBuilder(1); // already exists + $qb->expects($this->never())->method('executeStatement'); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user', $backend->checkPassword('user', 'pass')); + } + + public function testBasicAuthWrongPasswordReturnsFalse(): void { + $backend = $this->makeBackend(); + $backend->fetchResponses = [['HTTP/1.1 401 Unauthorized']]; + + $this->assertFalse($backend->checkPassword('user', 'wrongpass')); + } + + public function testBasicAuthConnectionFailureReturnsFalse(): void { + $backend = $this->makeBackend(); + $backend->fetchResponses = [null]; + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + // ------------------------------------------------------------------------- + // Digest auth + // ------------------------------------------------------------------------- + + private function digestChallenge(string $realm = 'example', string $nonce = 'abc123', string $opaque = 'xyz'): array { + return [ + 'HTTP/1.1 401 Unauthorized', + "WWW-Authenticate: Digest realm=\"{$realm}\", nonce=\"{$nonce}\", qop=\"auth\", opaque=\"{$opaque}\"", + ]; + } + + public function testDigestAuthSuccessStoresAndReturnsUid(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge(), // challenge request + ['HTTP/1.1 200 OK'], // authenticated request + ]; + + $qb = $this->mockQueryBuilder(0); + $qb->expects($this->once())->method('executeStatement'); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user', $backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthWrongPasswordReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge(), + ['HTTP/1.1 401 Unauthorized'], + ]; + + $this->assertFalse($backend->checkPassword('user', 'wrongpass')); + } + + public function testDigestAuthConnectionFailureOnChallengeReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [null]; // server unreachable + $this->logger->expects($this->atLeast(1))->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthNoChallengeHeaderReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [['HTTP/1.1 200 OK']]; // no WWW-Authenticate header + $this->logger->expects($this->atLeast(1))->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthConnectionFailureOnAuthRequestReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge(), + null, // authenticated request fails + ]; + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthComputesCorrectResponseHashWithQop(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge('myrealm', 'mynonce'), + ['HTTP/1.1 200 OK'], + ]; + + $qb = $this->mockQueryBuilder(1); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('alice', $backend->checkPassword('alice', 's3cr3t')); + + $authContext = $backend->capturedContexts[1]; + $opts = stream_context_get_options($authContext); + $header = $opts['http']['header']; + + $this->assertStringContainsString('uri="/dav"', $header); + $this->assertStringContainsString('qop=auth', $header); + + $this->assertSame(1, preg_match('/response="([^"]+)"/', $header, $m)); + $this->assertSame(1, preg_match('/cnonce="([^"]+)"/', $header, $cm)); + $cnonce = $cm[1]; + + $A1 = md5('alice:myrealm:s3cr3t'); + $A2 = md5('HEAD:/dav'); + $expected = md5($A1 . ':mynonce:00000001:' . $cnonce . ':auth:' . $A2); + $this->assertSame($expected, $m[1]); + } + + public function testDigestAuthComputesCorrectResponseHashWithoutQop(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + ['HTTP/1.1 401 Unauthorized', 'WWW-Authenticate: Digest realm="myrealm", nonce="mynonce"'], + ['HTTP/1.1 200 OK'], + ]; + + $qb = $this->mockQueryBuilder(1); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('alice', $backend->checkPassword('alice', 's3cr3t')); + + $authContext = $backend->capturedContexts[1]; + $opts = stream_context_get_options($authContext); + $header = $opts['http']['header']; + + $this->assertStringContainsString('uri="/dav"', $header); + $this->assertStringNotContainsString('qop=', $header); + $this->assertStringNotContainsString('cnonce=', $header); + + $this->assertSame(1, preg_match('/response="([^"]+)"/', $header, $m)); + + $A1 = md5('alice:myrealm:s3cr3t'); + $A2 = md5('HEAD:/dav'); + $expected = md5($A1 . ':mynonce:' . $A2); + $this->assertSame($expected, $m[1]); + } + + public function testDigestAuthWithOpaqueIncludedInHeader(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge('realm', 'nonce', 'opaquevalue'), + ['HTTP/1.1 200 OK'], + ]; + + $qb = $this->mockQueryBuilder(1); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user', $backend->checkPassword('user', 'pass')); + + $authContext = $backend->capturedContexts[1]; + $opts = stream_context_get_options($authContext); + $header = $opts['http']['header']; + $this->assertStringContainsString('opaque="opaquevalue"', $header); + } + + public function testDigestAuthUnsupportedAlgorithmReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + ['HTTP/1.1 401 Unauthorized', 'WWW-Authenticate: Digest realm="r", nonce="n", algorithm="SHA-256"'], + ]; + $this->logger->expects($this->atLeast(1))->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthEscapesSpecialCharactersInUsername(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + $this->digestChallenge(), + ['HTTP/1.1 200 OK'], + ]; + + $qb = $this->mockQueryBuilder(1); + $this->db->method('getQueryBuilder')->willReturn($qb); + + $this->assertSame('user"evil', $backend->checkPassword('user"evil', 'pass')); + + $authContext = $backend->capturedContexts[1]; + $opts = stream_context_get_options($authContext); + $header = $opts['http']['header']; + $this->assertStringContainsString('username="user\\"evil"', $header); + $this->assertStringNotContainsString("\r", $header); + $this->assertStringNotContainsString("\n", $header); + } + + public function testDigestAuthAuthIntOnlyReturnsFalse(): void { + $backend = $this->makeBackend('https://example.com/dav', 'digest'); + $backend->fetchResponses = [ + ['HTTP/1.1 401 Unauthorized', 'WWW-Authenticate: Digest realm="r", nonce="n", qop="auth-int"'], + ]; + $this->logger->expects($this->atLeast(1))->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testBasicAuthRedirectLogsError(): void { + $backend = $this->makeBackend(); + $backend->fetchResponses = [['HTTP/1.1 302 Found']]; + $this->logger->expects($this->atLeast(1))->method('error'); + + $this->assertFalse($backend->checkPassword('user', 'pass')); + } +}