Skip to content

Commit 5e470c3

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 5e470c3

3 files changed

Lines changed: 410 additions & 16 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# This workflow is provided via the organization template repository
2+
#
3+
# https://github.com/nextcloud/.github
4+
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
5+
#
6+
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
# SPDX-License-Identifier: MIT
8+
9+
name: Block unconventional commits
10+
11+
on:
12+
pull_request:
13+
types: [opened, ready_for_review, reopened, synchronize]
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: block-unconventional-commits-${{ github.head_ref || github.run_id }}
20+
cancel-in-progress: true
21+
22+
jobs:
23+
block-unconventional-commits:
24+
name: Block unconventional commits
25+
26+
runs-on: ubuntu-latest-low
27+
28+
steps:
29+
- name: Checkout
30+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
31+
with:
32+
persist-credentials: false
33+
34+
- uses: webiny/action-conventional-commits@faccb24fc2550dd15c0390d944379d2d8ed9690e # v1.3.1
35+
with:
36+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

lib/WebDavAuth.php

Lines changed: 136 additions & 16 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,162 @@
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

3146
$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']);
47+
if (count($arr) !== 2) {
48+
$this->logger->error('Invalid WebDAV URL: "' . $this->webDavAuthUrl . '"', ['app' => 'user_external']);
3449
return false;
3550
}
3651
[$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']);
52+
$url = $protocol . '://' . $path;
53+
54+
switch ($this->authType) {
55+
case 'basic':
56+
$responseHeaders = $this->fetchWithBasicAuth($url, $uid, $password);
57+
break;
58+
case 'digest':
59+
$responseHeaders = $this->fetchWithDigestAuth($url, $uid, $password);
60+
break;
61+
default:
62+
$this->logger->error(
63+
'Invalid WebDAV auth type: "' . $this->authType . '". Expected "basic" or "digest".',
64+
['app' => 'user_external'],
65+
);
66+
return false;
67+
}
68+
69+
if ($responseHeaders === null) {
4170
return false;
4271
}
43-
$returnCode = substr($headers[0], 9, 3);
4472

45-
if (substr($returnCode, 0, 1) === '2') {
73+
$returnCode = substr($responseHeaders[0], 9, 3);
74+
if (str_starts_with($returnCode, '2')) {
4675
$this->storeUser($uid);
4776
return $uid;
77+
}
78+
return false;
79+
}
80+
81+
/**
82+
* Perform a GET request with HTTP Basic authentication.
83+
*
84+
* @return string[]|null Response headers, or null on connection failure.
85+
*/
86+
protected function fetchWithBasicAuth(string $url, string $uid, string $password): ?array {
87+
$context = stream_context_create(['http' => [
88+
'method' => 'GET',
89+
'header' => 'Authorization: Basic ' . base64_encode($uid . ':' . $password),
90+
'ignore_errors' => true,
91+
]]);
92+
return $this->fetchUrl($url, $context);
93+
}
94+
95+
/**
96+
* Perform a two-step GET request with HTTP Digest authentication.
97+
*
98+
* @return string[]|null Response headers, or null on connection failure or missing challenge.
99+
*/
100+
protected function fetchWithDigestAuth(string $url, string $uid, string $password): ?array {
101+
// Step 1: unauthenticated request to receive the server challenge
102+
$challengeHeaders = $this->fetchUrl($url);
103+
if ($challengeHeaders === null) {
104+
$this->logger->error('Not possible to connect to WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
105+
return null;
106+
}
107+
108+
// Step 2: find the WWW-Authenticate: Digest header
109+
$authHeaderValue = null;
110+
foreach ($challengeHeaders as $header) {
111+
if (stripos($header, 'WWW-Authenticate: Digest ') === 0) {
112+
$authHeaderValue = substr($header, strlen('WWW-Authenticate: Digest '));
113+
break;
114+
}
115+
}
116+
117+
if ($authHeaderValue === null) {
118+
$this->logger->error('No Digest challenge received from WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
119+
return null;
120+
}
121+
122+
// Step 3: parse the challenge parameters
123+
$params = [];
124+
preg_match_all('/(\w+)="([^"]*)"/', $authHeaderValue, $matches, PREG_SET_ORDER);
125+
foreach ($matches as $m) {
126+
$params[$m[1]] = $m[2];
127+
}
128+
129+
if (!isset($params['realm'], $params['nonce'])) {
130+
$this->logger->error('Invalid Digest challenge from WebDAV URL: "' . $url . '"', ['app' => 'user_external']);
131+
return null;
132+
}
133+
134+
// Step 4: compute the digest response
135+
$cnonce = bin2hex(random_bytes(8));
136+
$nc = '00000001';
137+
$A1 = md5($uid . ':' . $params['realm'] . ':' . $password);
138+
$A2 = md5('GET:' . $url);
139+
$response = md5($A1 . ':' . $params['nonce'] . ':' . $nc . ':' . $cnonce . ':auth:' . $A2);
140+
141+
$digestHeader = sprintf(
142+
'Authorization: Digest username="%s", realm="%s", nonce="%s", uri="%s", cnonce="%s", nc=%s, qop=auth, response="%s"',
143+
$uid, $params['realm'], $params['nonce'], $url, $cnonce, $nc, $response,
144+
);
145+
if (isset($params['opaque'])) {
146+
$digestHeader .= sprintf(', opaque="%s"', $params['opaque']);
147+
}
148+
149+
// Step 5: send the authenticated request
150+
$context = stream_context_create(['http' => [
151+
'method' => 'GET',
152+
'header' => $digestHeader,
153+
'ignore_errors' => true,
154+
]]);
155+
return $this->fetchUrl($url, $context);
156+
}
157+
158+
/**
159+
* Perform a GET request and return the response headers.
160+
* Extracted so tests can stub network calls without hitting the wire.
161+
*
162+
* @return string[]|null Response headers, or null if the server is unreachable.
163+
*/
164+
protected function fetchUrl(string $url, mixed $context = null): ?array {
165+
if ($context !== null) {
166+
@file_get_contents($url, false, $context);
48167
} else {
49-
return false;
168+
@file_get_contents($url);
50169
}
170+
return $http_response_header ?? null;
51171
}
52172
}

0 commit comments

Comments
 (0)