Skip to content
Open
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
152 changes: 136 additions & 16 deletions lib/WebDavAuth.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

/**
* Copyright (c) 2015 Thomas Müller <thomas.mueller@tmit.eu>
* This file is licensed under the Affero General Public License version 3 or
Expand All @@ -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;
}
}
Loading
Loading