diff --git a/lib/WebDavAuth.php b/lib/WebDavAuth.php index ab4cc72..2fb068f 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,162 @@ 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) { + public function checkPassword($uid, $password): string|false { $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']); + if (count($arr) !== 2) { + $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 = $protocol . '://' . $path; + + 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 GET 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' => 'GET', + 'header' => 'Authorization: Basic ' . base64_encode($uid . ':' . $password), + 'ignore_errors' => true, + ]]); + return $this->fetchUrl($url, $context); + } + + /** + * Perform a two-step GET 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 + $challengeHeaders = $this->fetchUrl($url); + if ($challengeHeaders === null) { + $this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['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+)="([^"]*)"/', $authHeaderValue, $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $params[$m[1]] = $m[2]; + } + + if (!isset($params['realm'], $params['nonce'])) { + $this->logger->error('Invalid Digest challenge from WebDAV URL: "' . $url . '"', ['app' => 'user_external']); + return null; + } + + // Step 4: compute the digest response + $cnonce = bin2hex(random_bytes(8)); + $nc = '00000001'; + $A1 = md5($uid . ':' . $params['realm'] . ':' . $password); + $A2 = md5('GET:' . $url); + $response = md5($A1 . ':' . $params['nonce'] . ':' . $nc . ':' . $cnonce . ':auth:' . $A2); + + $digestHeader = sprintf( + 'Authorization: Digest username="%s", realm="%s", nonce="%s", uri="%s", cnonce="%s", nc=%s, qop=auth, response="%s"', + $uid, $params['realm'], $params['nonce'], $url, $cnonce, $nc, $response, + ); + if (isset($params['opaque'])) { + $digestHeader .= sprintf(', opaque="%s"', $params['opaque']); + } + + // Step 5: send the authenticated request + $context = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => $digestHeader, + 'ignore_errors' => true, + ]]); + return $this->fetchUrl($url, $context); + } + + /** + * Perform a GET 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 { + if ($context !== null) { + @file_get_contents($url, false, $context); } else { - return false; + @file_get_contents($url); } + return $http_response_header ?? null; } } diff --git a/tests/unit/WebDavAuthTest.php b/tests/unit/WebDavAuthTest.php new file mode 100644 index 0000000..77e378d --- /dev/null +++ b/tests/unit/WebDavAuthTest.php @@ -0,0 +1,238 @@ + */ + public array $fetchResponses = []; + + protected function fetchUrl(string $url, mixed $context = null): ?array { + 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')); + } + + // ------------------------------------------------------------------------- + // 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->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->once())->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->once())->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->assertFalse($backend->checkPassword('user', 'pass')); + } + + public function testDigestAuthComputesCorrectResponseHash(): void { + // Verify the digest computation: if our hash matches, a 200 comes back. + // We use known values and confirm the uid is returned. + $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); // existing user, skip insert + $this->db->method('getQueryBuilder')->willReturn($qb); + + // The real MD5 computation runs — if it doesn't crash and returns uid, the logic is sound. + $this->assertSame('alice', $backend->checkPassword('alice', 's3cr3t')); + } + + public function testDigestAuthWithOpaqueIncludedInHeader(): void { + // Ensure opaque is included when present (regression: was previously omitted on missing key) + $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')); + } +}