Skip to content

Commit 01fc8bb

Browse files
committed
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 <noreply@anthropic.com> Signed-off-by: Anna Larch <anna@nextcloud.com>
1 parent 69d1cc6 commit 01fc8bb

2 files changed

Lines changed: 588 additions & 18 deletions

File tree

lib/WebDavAuth.php

Lines changed: 238 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
/**
46
* Copyright (c) 2015 Thomas Müller <thomas.mueller@tmit.eu>
57
* This file is licensed under the Affero General Public License version 3 or
@@ -9,44 +11,262 @@
911

1012
namespace OCA\UserExternal;
1113

14+
use OCP\IDBConnection;
15+
use OCP\IGroupManager;
16+
use OCP\IUserManager;
17+
use Psr\Log\LoggerInterface;
18+
1219
class WebDavAuth extends Base {
13-
private $webDavAuthUrl;
20+
private string $webDavAuthUrl;
21+
private string $authType;
1422

15-
public function __construct($webDavAuthUrl) {
16-
parent::__construct($webDavAuthUrl);
23+
public function __construct(
24+
string $webDavAuthUrl,
25+
string $authType = 'basic',
26+
?IDBConnection $db = null,
27+
?IUserManager $userManager = null,
28+
?IGroupManager $groupManager = null,
29+
?LoggerInterface $logger = null,
30+
) {
31+
parent::__construct($webDavAuthUrl, $db, $userManager, $groupManager, $logger);
1732
$this->webDavAuthUrl = $webDavAuthUrl;
33+
$this->authType = $authType;
1834
}
1935

2036
/**
21-
* Check if the password is correct without logging in the user
37+
* Check if the password is correct without logging in the user.
2238
*
2339
* @param string $uid The username
2440
* @param string $password The password
25-
*
26-
* @return true/false
41+
* @return string|false The uid on success, false on failure
2742
*/
28-
public function checkPassword($uid, $password) {
43+
public function checkPassword($uid, $password): string|false {
2944
$uid = $this->resolveUid($uid);
3045

31-
$arr = explode('://', $this->webDavAuthUrl, 2);
32-
if (! isset($arr) or count($arr) !== 2) {
33-
$this->logger->error('ERROR: Invalid WebdavUrl: "' . $this->webDavAuthUrl . '" ', ['app' => 'user_external']);
46+
$parsed = parse_url($this->webDavAuthUrl);
47+
if ($parsed === false
48+
|| !isset($parsed['scheme'], $parsed['host'])
49+
|| !in_array($parsed['scheme'], ['http', 'https'], true)
50+
|| isset($parsed['user'])
51+
) {
52+
$this->logger->error('Invalid WebDAV URL: "' . $this->webDavAuthUrl . '"', ['app' => 'user_external']);
3453
return false;
3554
}
36-
[$protocol, $path] = $arr;
37-
$url = $protocol . '://' . urlencode($uid) . ':' . urlencode($password) . '@' . $path;
38-
$headers = get_headers($url);
39-
if ($headers === false) {
40-
$this->logger->error('ERROR: Not possible to connect to WebDAV Url: "' . $protocol . '://' . $path . '" ', ['app' => 'user_external']);
55+
$url = $this->webDavAuthUrl;
56+
57+
switch ($this->authType) {
58+
case 'basic':
59+
$responseHeaders = $this->fetchWithBasicAuth($url, $uid, $password);
60+
break;
61+
case 'digest':
62+
$responseHeaders = $this->fetchWithDigestAuth($url, $uid, $password);
63+
break;
64+
default:
65+
$this->logger->error(
66+
'Invalid WebDAV auth type: "' . $this->authType . '". Expected "basic" or "digest".',
67+
['app' => 'user_external'],
68+
);
69+
return false;
70+
}
71+
72+
if ($responseHeaders === null) {
4173
return false;
4274
}
43-
$returnCode = substr($headers[0], 9, 3);
4475

45-
if (substr($returnCode, 0, 1) === '2') {
76+
$returnCode = substr($responseHeaders[0], 9, 3);
77+
if (str_starts_with($returnCode, '2')) {
4678
$this->storeUser($uid);
4779
return $uid;
80+
}
81+
return false;
82+
}
83+
84+
/**
85+
* Perform a HEAD request with HTTP Basic authentication.
86+
*
87+
* @return string[]|null Response headers, or null on connection failure.
88+
*/
89+
protected function fetchWithBasicAuth(string $url, string $uid, string $password): ?array {
90+
$context = stream_context_create(['http' => [
91+
'method' => 'HEAD',
92+
'header' => 'Authorization: Basic ' . base64_encode($uid . ':' . $password),
93+
'ignore_errors' => true,
94+
'follow_location' => 0,
95+
]]);
96+
$responseHeaders = $this->fetchUrl($url, $context);
97+
if ($responseHeaders === null) {
98+
$this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
99+
return null;
100+
}
101+
102+
$returnCode = substr($responseHeaders[0], 9, 3);
103+
if (str_starts_with($returnCode, '3')) {
104+
$this->logger->error(
105+
'WebDAV URL returned a redirect (' . $returnCode . '). Redirects are not followed for authenticated requests to prevent credential leaking.',
106+
['app' => 'user_external'],
107+
);
108+
return null;
109+
}
110+
111+
return $responseHeaders;
112+
}
113+
114+
/**
115+
* Perform a two-step HEAD request with HTTP Digest authentication.
116+
*
117+
* @return string[]|null Response headers, or null on connection failure or missing challenge.
118+
*/
119+
protected function fetchWithDigestAuth(string $url, string $uid, string $password): ?array {
120+
// Step 1: unauthenticated request to receive the server challenge
121+
$challengeContext = stream_context_create(['http' => [
122+
'method' => 'HEAD',
123+
'ignore_errors' => true,
124+
'follow_location' => 0,
125+
]]);
126+
$challengeHeaders = $this->fetchUrl($url, $challengeContext);
127+
if ($challengeHeaders === null) {
128+
$this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
129+
return null;
130+
}
131+
132+
$challengeCode = substr($challengeHeaders[0], 9, 3);
133+
if (str_starts_with($challengeCode, '3')) {
134+
$this->logger->error(
135+
'WebDAV Digest challenge returned a redirect (' . $challengeCode . '). Redirects are not followed to prevent sending credentials to an unintended host.',
136+
['app' => 'user_external'],
137+
);
138+
return null;
139+
}
140+
141+
// Step 2: find the WWW-Authenticate: Digest header
142+
$authHeaderValue = null;
143+
foreach ($challengeHeaders as $header) {
144+
if (stripos($header, 'WWW-Authenticate: Digest ') === 0) {
145+
$authHeaderValue = substr($header, strlen('WWW-Authenticate: Digest '));
146+
break;
147+
}
148+
}
149+
150+
if ($authHeaderValue === null) {
151+
$this->logger->error('No Digest challenge received from WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
152+
return null;
153+
}
154+
155+
// Step 3: parse the challenge parameters
156+
$params = [];
157+
preg_match_all('/(\w+)="([^"]*)"/', $authHeaderValue, $matches, PREG_SET_ORDER);
158+
foreach ($matches as $m) {
159+
$params[$m[1]] = $m[2];
160+
}
161+
162+
if (!isset($params['realm'], $params['nonce'])) {
163+
$this->logger->error('Invalid Digest challenge from WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
164+
return null;
165+
}
166+
167+
$algorithm = $params['algorithm'] ?? 'MD5';
168+
if ($algorithm !== 'MD5') {
169+
$this->logger->error(
170+
'Unsupported Digest algorithm: "' . $algorithm . '". Only MD5 is supported.',
171+
['app' => 'user_external'],
172+
);
173+
return null;
174+
}
175+
176+
// Step 4: compute the digest response
177+
$parsedUrl = parse_url($url);
178+
$uri = $parsedUrl['path'] ?? '/';
179+
if (isset($parsedUrl['query'])) {
180+
$uri .= '?' . $parsedUrl['query'];
181+
}
182+
183+
$qopTokens = isset($params['qop']) ? array_map('trim', explode(',', $params['qop'])) : [];
184+
$useQop = in_array('auth', $qopTokens, true);
185+
if (!empty($qopTokens) && !$useQop) {
186+
$this->logger->error(
187+
'Unsupported Digest qop: "' . $params['qop'] . '". Only "auth" is supported.',
188+
['app' => 'user_external'],
189+
);
190+
return null;
191+
}
192+
193+
try {
194+
$A1 = md5($uid . ':' . $params['realm'] . ':' . $password);
195+
$A2 = md5('HEAD:' . $uri);
196+
197+
if ($useQop) {
198+
$cnonce = bin2hex(random_bytes(8));
199+
$nc = '00000001';
200+
$response = md5($A1 . ':' . $params['nonce'] . ':' . $nc . ':' . $cnonce . ':auth:' . $A2);
201+
} else {
202+
$response = md5($A1 . ':' . $params['nonce'] . ':' . $A2);
203+
}
204+
} catch (\Throwable $e) {
205+
$this->logger->error('Failed to compute Digest response: ' . $e->getMessage(), ['app' => 'user_external']);
206+
return null;
207+
}
208+
209+
$digestHeader = sprintf(
210+
'Authorization: Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"',
211+
$this->escapeDigestValue($uid),
212+
$this->escapeDigestValue($params['realm']),
213+
$this->escapeDigestValue($params['nonce']),
214+
$this->escapeDigestValue($uri),
215+
$response,
216+
);
217+
if ($useQop) {
218+
$digestHeader .= sprintf(', cnonce="%s", nc=%s, qop=auth', $cnonce, $nc);
219+
}
220+
if (isset($params['opaque'])) {
221+
$digestHeader .= sprintf(', opaque="%s"', $this->escapeDigestValue($params['opaque']));
222+
}
223+
224+
// Step 5: send the authenticated request
225+
$context = stream_context_create(['http' => [
226+
'method' => 'HEAD',
227+
'header' => $digestHeader,
228+
'ignore_errors' => true,
229+
'follow_location' => 0,
230+
]]);
231+
$responseHeaders = $this->fetchUrl($url, $context);
232+
if ($responseHeaders === null) {
233+
$this->logger->error('Digest authenticated request failed for WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
234+
return null;
235+
}
236+
237+
$authCode = substr($responseHeaders[0], 9, 3);
238+
if (str_starts_with($authCode, '3')) {
239+
$this->logger->error(
240+
'WebDAV Digest authenticated request returned a redirect (' . $authCode . '). Redirects are not followed to prevent credential leaking.',
241+
['app' => 'user_external'],
242+
);
243+
return null;
244+
}
245+
246+
return $responseHeaders;
247+
}
248+
249+
private function escapeDigestValue(string $value): string {
250+
$value = str_replace(["\r", "\n"], '', $value);
251+
return addcslashes($value, '"\\');
252+
}
253+
254+
/**
255+
* Perform an HTTP request and return the response headers.
256+
* Extracted so tests can stub network calls without hitting the wire.
257+
*
258+
* @return string[]|null Response headers, or null if the server is unreachable.
259+
*/
260+
protected function fetchUrl(string $url, mixed $context = null): ?array {
261+
$http_response_header = null;
262+
if ($context !== null) {
263+
$result = @file_get_contents($url, false, $context);
48264
} else {
49-
return false;
265+
$result = @file_get_contents($url);
266+
}
267+
if ($result === false && $http_response_header === null) {
268+
return null;
50269
}
270+
return $http_response_header;
51271
}
52272
}

0 commit comments

Comments
 (0)