Skip to content

Commit 20b0069

Browse files
author
Alexey Abel
committed
Implement DSN-based database connection
The DSN-based connection configuration is closer to what PHP does with PDO objects under the hood and this allows the use of more scenarios such as socket connections and more databases out-of-the-box without user_backend_sql_raw having to provide configuration parameters for each option and database.
1 parent d6c8a61 commit 20b0069

File tree

11 files changed

+164
-416
lines changed

11 files changed

+164
-416
lines changed

CHANGELOG.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
# Changelog
22

3-
All notable changes to this project will be documented in this file.
3+
All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4+
5+
## [Unreleased]
6+
7+
### Addded
8+
9+
* DSN-based database connection mechanism. This enables support for socket-based database connections and also connections to Firebird, MS SQL, Oracle DB, ODBC, DB2, SQLite, Informix and IBM databases - basically whatever the [PHP PDO-driver](https://www.php.net/manual/en/pdo.drivers.php) supports. But PostgreSQL remains the only tested database and MySQL/MariaDB to some degree. The other databaes should "just work", but this has not been tested.
10+
* `dsn` configuration key
11+
* dependancy on PHP >=8.0
12+
13+
### Removed
14+
15+
* **Breaking**: remove configuration keys `db_type`, `db_host`, `db_port`, `db_name`, `mariadb_charset`. These settings must now be included in the DSN string. See [README.md](README.md#1database) on how to do this.
16+
* support for Nextcloud <26, because Nextcloud 26 is the first to require PHP 8.0, which this app now also requires
417

5-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
718

819
## [1.5.1] - 2024-05-01
920

README.md

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ Argon2id. Because the various formats are recognized on-the-fly your db can can
2222
have differing hash string formats at the same time, which eases migration to
2323
newer formats.
2424

25-
This app supports PostgreSQL and MariaDB/MySQL.
25+
This app primarily supports PostgreSQL and MariaDB/MySQL but the underlying PHP
26+
[mechanism](https://www.php.net/manual/en/pdo.drivers.php) also supports
27+
Firebird, MS SQL, Oracle DB, ODBC, DB2, SQLite, Informix and IBM databases. By
28+
using an appropriate DSN you should be able to connect to these databases. This
29+
has not been tested, though.
2630

2731
See [CHANGELOG.md](CHANGELOG.md) for changes in newer versions. This app follows
2832
semantic versioning and there should not be any breaking changes unless the
@@ -42,14 +46,10 @@ This app has no user interface. All configuration is done via Nextcloud's system
4246

4347
```php
4448
'user_backend_sql_raw' => array(
45-
//'db_type' => 'postgresql',
46-
//'db_host' => 'localhost',
47-
//'db_port' => '5432',
48-
'db_name' => 'theNameOfYourUserDatabase',
49-
'db_user' => 'yourDatabaseUser',
50-
'db_password' => 'thePasswordForTheDatabaseUser',
51-
//'db_password_file' => '/var/secrets/fileContainingThePasswordForTheDatabaseUser',
52-
//'mariadb_charset' => 'utf8mb4',
49+
'dsn' => 'pgsql:host=/var/run/postgresql;dbname=theNameOfYourUserDb',
50+
//'db_user' => 'yourDatabaseUser',
51+
//'db_password' => 'thePasswordForTheDatabaseUser',
52+
//'db_password_file' => '/path/to/file/ContainingThePasswordForTheDatabaseUser',
5353
'queries' => array(
5454
'get_password_hash_for_user' => 'SELECT password_hash FROM users_fqda WHERE fqda = :username',
5555
'user_exists' => 'SELECT EXISTS(SELECT 1 FROM users_fqda WHERE fqda = :username)',
@@ -70,28 +70,28 @@ There are three types of configuration parameters:
7070

7171
### 1. Database
7272

73-
that *User Backend SQL Raw* will connect to.
74-
75-
| key | value | default value |
76-
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------- |
77-
| `db_type` | `postgresql` or `mariadb` | `postgresql` |
78-
| `db_host` | your db host such as `localhost` or `db.example.com` or (only for PostgreSQL) path to socket, e.g. `/var/run/postgresql` | `localhost` |
79-
| `db_port` | your db port | `5432` |
80-
| `db_name` | your db name | |
81-
| `db_user` | your db user | |
82-
| `db_password` | your db password | |
83-
| `db_password_file` | path to file containing the db password | |
84-
| `mariadb_charset` | the charset for mariadb connections | `utf8mb4` |
85-
86-
* Values without a default value are mandatory, except that
87-
* only one of `db_password` or `db_passowrd_file` must be set.
88-
* Only the first line of the file specified by `db_passowrd_file` is read.
73+
that *User Backend SQL Raw* will connect to. There are two methods to configure the database connection:
74+
75+
1. Set `dsn` to a DSN which contains the entire db connnection configuration including the db user and db password
76+
2. Set `dsn` to DSN that contains everything **but** the db user and db password and then set `db_user` and `db_password`/`db_password_file`
77+
78+
PostgreSQL works with method 1 and 2. MySQL works only with method 2. If you use `db_password_file` also set `db_user` and don't put the username in the DSN. This is because, the underlying PDO classes have some quirks, so don't mix both methods.
79+
80+
* `dsn`: check how to construct DSNs for [PostgreSQL](https://www.php.net/manual/en/ref.pdo-pgsql.connection.php) and [MySQL](https://www.php.net/manual/en/ref.pdo-mysql.connection.php). Examples:
81+
* connect to PostgreSQL via a socket with ident authentication which requires no user or password at all: `pgsql:host=/var/run/postgresql;dbname=theNameOfYourUserDb`
82+
* connect to PostgreSQL via TCP and user/password authentication: `pgsql:host=localhost;port=5432;dbname=theNameOfYourUserDb;user=theNameOfYourDbUser;password=thePasswordForTheDbUser`
83+
* connect to PostgreSQL via TCP and user/password authentication and use password file: `pgsql:host=localhost;port=5432;dbname=theNameOfYourUserDb` and then also set `db_user` and `db_password_file`
84+
* connect to MySQL via socket which requires no user or password at all: `mysql:unix_socket=/var/run/mysql/mysql.sock;dbname=theNameOfYourUserDb`
85+
* connect to MySQL via TCP and user/password authentication: `mysql:host=localhost;port=3306;dbname=testdb` and then also set `db_user` and (`db_password` or `db_password_file`)
86+
* `db_user`: user that will be used to connect to the database
87+
* `db_password`: password for the user that will be used to connect to the database
88+
* `db_password_file`: Can be set to read the password from a file. Has higher priority than `db_password`, but lower priority than password in DSN. Due to the already mentioned quirks, it's better not to mix both.
89+
* Only the first line of the file specified by `db_password_file` is read.
8990
* Not more than 100 characters of the first line are read.
90-
* Whitespace-like characters are [stripped](https://www.php.net/manual/en/function.trim.php) from
91+
* Whitespace-like characters are [trimmed](https://www.php.net/manual/en/function.trim.php) from
9192
the beginning and end of the read password.
92-
* If you specify a socket as `db_host` (only for PostgreSQL), you need to put
93-
dummy values for the mandatory values, although they are not required for the
94-
socket connection. This will be fixed in a future release.
93+
94+
For other databases check their [PDO driver documentation pages](https://www.php.net/manual/en/pdo.drivers.php) which in-turn link to their respective DSN references. They either use method 1 or method 2 AFAICS.
9595

9696
### 2. SQL Queries
9797

@@ -136,8 +136,6 @@ The config values are `md5`, `sha256`, `sha512`, `argon2i`, `argon2id` respectiv
136136
* This means, that your db can have different hash formats simultaneously. Whenever a
137137
user's password is changed, it will be updated to the configured hash algorithm. This eases
138138
migration to more modern algorithms.
139-
* Argon2i is only supported by PHP 7.2.0 and higher.
140-
* Argon2id is only supported by PHP 7.3.0 and higher.
141139

142140
## Security
143141

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ In contrast to the app *SQL user backend*, you write the SQL queries yourself. Y
1414
The app uses prepared statements and is written to be secure by default to prevent SQL injections. It understands the most popular standards for password hash formats: MD5-CRYPT, SHA256-CRYPT, SHA512-CRYPT, BCrypt and the state-of-the-art Argon2i and Argon2id. Because the various formats are recognized on-the-fly your db can can have differing hash string formats at the same time, which eases migration to newer formats.
1515
1616
This app supports PostgreSQL and MariaDB/MySQL.]]></description>
17-
<version>1.5.1</version>
17+
<version>2.0.0</version>
1818
<licence>agpl</licence>
1919
<author mail="dev@abelonline.de" >Alexey Abel</author>
2020
<namespace>UserBackendSqlRaw</namespace>

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
}
2525
},
2626
"require": {
27-
"php": ">=7.0"
27+
"php": ">=8.0"
2828
},
2929
"require-dev": {
30-
"php": ">=7.3",
30+
"php": ">=8.0",
3131
"phpunit/phpunit": "^9"
3232
}
3333
}

lib/AppInfo/Application.php

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
namespace OCA\UserBackendSqlRaw\AppInfo;
2323

24+
use OCA\UserBackendSqlRaw\Db;
2425
use OCP\AppFramework\Bootstrap\IBootContext;
2526
use OCP\AppFramework\Bootstrap\IBootstrap;
2627
use OCP\AppFramework\Bootstrap\IRegistrationContext;
@@ -38,22 +39,6 @@ public function __construct(array $urlParams = array())
3839

3940
public function register(IRegistrationContext $context): void
4041
{
41-
/**
42-
* "Service" in this context is simply a class that you want to be able to inject.
43-
*
44-
* We don't have to register all classes because they can be auto-wired, but OCA\UserBackendSqlRaw\Db
45-
* is an abstract class and we need to manually define what Nextcloud will return when someone
46-
* queries (requests an instance of) this class. We query Config first (this one was auto-wired) and
47-
* use it's getDbType() method to instantiate the proper Db class by name.
48-
*
49-
* Nextcloud's dependency injection is partly explained in:
50-
* https://docs.nextcloud.com/server/latest/developer_manual/basics/dependency_injection.html#how-to-deal-with-interface-and-primitive-type-parameters
51-
*/
52-
$context->registerService('OCA\UserBackendSqlRaw\Db', function (ContainerInterface $container) {
53-
/** @var \OCA\UserBackendSqlRaw\Config $config */
54-
$config = $container->get('OCA\UserBackendSqlRaw\Config');
55-
return $container->get('OCA\UserBackendSqlRaw\Dbs\\' . ucfirst($config->getDbType()));
56-
});
5742
}
5843

5944
public function boot(IBootContext $context): void

lib/Config.php

Lines changed: 39 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -26,25 +26,14 @@
2626

2727
class Config
2828
{
29-
30-
const DEFAULT_DB_TYPE = 'postgresql';
31-
const DEFAULT_DB_HOST = 'localhost';
32-
const DEFAULT_POSTGRESQL_PORT = '5432';
33-
const DEFAULT_MARIADB_PORT = '3306';
34-
const DEFAULT_MARIADB_CHARSET = 'utf8mb4';
3529
const DEFAULT_HASH_ALGORITHM_FOR_NEW_PASSWORDS = 'bcrypt';
36-
3730
const MAXIMUM_ALLOWED_PASSWORD_LENGTH = 100;
3831

3932
const CONFIG_KEY = 'user_backend_sql_raw';
40-
const CONFIG_KEY_DB_TYPE = 'db_type';
41-
const CONFIG_KEY_DB_HOST = 'db_host';
42-
const CONFIG_KEY_DB_PORT = 'db_port';
43-
const CONFIG_KEY_DB_NAME = 'db_name';
33+
const CONFIG_KEY_DSN = 'dsn';
4434
const CONFIG_KEY_DB_USER = 'db_user';
4535
const CONFIG_KEY_DB_PASSWORD = 'db_password';
4636
const CONFIG_KEY_DB_PASSWORD_FILE = 'db_password_file';
47-
const CONFIG_KEY_MARIADB_CHARSET = 'mariadb_charset';
4837
const CONFIG_KEY_HASH_ALGORITHM_FOR_NEW_PASSWORDS = 'hash_algorithm_for_new_passwords';
4938

5039
const CONFIG_KEY_QUERIES = 'queries';
@@ -81,64 +70,43 @@ public function __construct(LoggerInterface $logger, IConfig $nextCloudConfigura
8170
. self::CONFIG_KEY . ' which should contain the configuration '
8271
. 'for the app user_backend_sql_raw.');
8372
}
84-
}
85-
86-
/**
87-
* @return string db type to connect to
88-
*/
89-
public function getDbType()
90-
{
91-
$dbTypeFromConfig = $this->getConfigValueOrDefaultValue(self::CONFIG_KEY_DB_TYPE
92-
, self::DEFAULT_DB_TYPE);
93-
94-
$normalizedDbType = $this->normalize($dbTypeFromConfig);
95-
96-
if (!$this->dbTypeIsSupported($normalizedDbType)) {
97-
throw new \UnexpectedValueException('The config key '
98-
. self::CONFIG_KEY_DB_TYPE . ' is set to ' . $dbTypeFromConfig . '. This '
99-
. 'value is invalid. Only postgresql and mariadb are supported.');
100-
}
101-
102-
return $normalizedDbType;
103-
}
10473

105-
/**
106-
* @return string db host to connect to
107-
*/
108-
public function getDbHost()
109-
{
110-
return $this->getConfigValueOrDefaultValue(self::CONFIG_KEY_DB_HOST
111-
, self::DEFAULT_DB_HOST);
74+
$this->warnAboutObsoleteConfigKeys();
11275
}
11376

114-
/**
115-
* @return int db port to connect to
116-
*/
117-
public function getDbPort()
77+
public function warnAboutObsoleteConfigKeys()
11878
{
119-
120-
$defaultPortForCurrentDb = ($this->getDbType() === 'mariadb')
121-
? self::DEFAULT_MARIADB_PORT
122-
: self::DEFAULT_POSTGRESQL_PORT;
123-
124-
return $this->getConfigValueOrDefaultValue(self::CONFIG_KEY_DB_PORT
125-
, $defaultPortForCurrentDb);
79+
$obsolete_keys = array("db_type", "db_host", "db_port", "db_name", "mariadb_charset");
80+
foreach ($obsolete_keys as $key) {
81+
// not using getConfigValueOrFalse() here, because we want to also catch empty strings
82+
if (array_key_exists(key: $key, array:$this->appConfiguration)) {
83+
$this->logger->warning("The configuration key '{$key}' is obsolete since "
84+
. "version 2.0.0. It has no effect and can be removed.");
85+
}
86+
}
12687
}
12788

12889
/**
129-
* @return string db name to connect to
90+
* @return string dsn to use for db connection
91+
* @throws \UnexpectedValueException
13092
*/
131-
public function getDbName()
93+
public function getDsn()
13294
{
133-
return $this->getConfigValueOrThrowException(self::CONFIG_KEY_DB_NAME);
95+
return $this->getConfigValueOrThrowException(self::CONFIG_KEY_DSN);
13496
}
13597

13698
/**
13799
* @return string db user to connect as
138100
*/
139101
public function getDbUser()
140102
{
141-
return $this->getConfigValueOrThrowException(self::CONFIG_KEY_DB_USER);
103+
return $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_USER);
104+
}
105+
106+
// Used instead of getDbPassword() when only needs to check if `db_password`
107+
// and not `db_password_file` or password in DSN is set.
108+
public function dbPasswordInConfigIsSet() : bool {
109+
return $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_PASSWORD) !== false;
142110
}
143111

144112
/**
@@ -147,24 +115,17 @@ public function getDbUser()
147115
*/
148116
public function getDbPassword()
149117
{
150-
151118
$password = $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_PASSWORD);
152119
$passwordFilePath = $this->getConfigValueOrFalse(self::CONFIG_KEY_DB_PASSWORD_FILE);
153120

154121
$passwordIsSet = $password !== false;
155122
$passwordFileIsSet = $passwordFilePath !== false;
156123

157-
if ($passwordIsSet === $passwordFileIsSet) { // expression is a "not XOR"
158-
throw new \UnexpectedValueException('Exactly one of ' . self::CONFIG_KEY_DB_PASSWORD . ' or ' . self::CONFIG_KEY_DB_PASSWORD_FILE . ' must be set (not be empty) in the config.');
159-
}
160-
161-
if ($passwordIsSet) {
162-
$this->logger->debug("Will use db password specified directly in config.php.");
163-
return $password;
164-
}
165-
124+
// Password from file (db_password_file) has higher priority than password from config (db_password).
166125
if ($passwordFileIsSet) {
167-
$this->logger->debug("Will use db password stored in file " . $passwordFilePath) . ".";
126+
$this->logger->debug("Will read db password stored in file " . $passwordFilePath)
127+
. ". Password from config file will not be considered. Password from DSN still has "
128+
."priority.";
168129
$error_message_prefix = "Specified db password file with path {$passwordFilePath}";
169130

170131
if (!file_exists($passwordFilePath)) {
@@ -189,17 +150,19 @@ public function getDbPassword()
189150
fclose($file);
190151
$this->logger->debug("Successfully read db password from file " . $passwordFilePath) . ".";
191152
return trim($first_line);
153+
} elseif ($passwordIsSet) {
154+
$this->logger->debug("Will read db password specified in config.php. Password from file"
155+
." was not specified. Password from DSN still has priority.");
156+
return $password;
157+
} else {
158+
return false;
192159
}
193160

194-
}
161+
// Priority of password in the DSN over both passwords read here is
162+
// implemented in the PDO implementation of PHP. It will simply ignore
163+
// the password given as a parameter during PDO object creation and use
164+
// the one from the DSN, if the DSN contains it.
195165

196-
/**
197-
* @return string charset for mariadb connection
198-
*/
199-
public function getMariadbCharset()
200-
{
201-
return $this->getConfigValueOrDefaultValue(self::CONFIG_KEY_MARIADB_CHARSET
202-
, self::DEFAULT_MARIADB_CHARSET);
203166
}
204167

205168
/**
@@ -219,23 +182,6 @@ public function getHashAlgorithmForNewPasswords()
219182
. 'to ' . $hashAlgorithmFromConfig . '. This value is invalid. Only '
220183
. 'md5, sha256, sha512, bcrypt, argon2i and argon2id are supported.');
221184
}
222-
223-
if ($normalizedHashAlgorithm === 'argon2i'
224-
&& version_compare(PHP_VERSION, '7.2.0', '<')) {
225-
throw new \UnexpectedValueException(
226-
'You specified Argon2i as the hash algorithm for new '
227-
. 'passwords. Argon2i is only available in PHP version 7.2.0 and'
228-
. ' higher, but your PHP version is ' . PHP_VERSION . '.');
229-
}
230-
231-
if ($normalizedHashAlgorithm === 'argon2id'
232-
&& version_compare(PHP_VERSION, '7.3.0', '<')) {
233-
throw new \UnexpectedValueException(
234-
'You specified Argon2id as the hash algorithm for new '
235-
. 'passwords. Argon2id is only available in PHP version 7.3.0 and'
236-
. ' higher, but your PHP version is ' . PHP_VERSION . '.');
237-
}
238-
239185
return $normalizedHashAlgorithm;
240186
}
241187

@@ -289,6 +235,8 @@ public function getQueryCreateUser()
289235
return $this->getQueryStringOrFalse(self::CONFIG_KEY_CREATE_USER);
290236
}
291237

238+
239+
292240
/**
293241
* Tries to read a config value and throws an exception if it is not set.
294242
* This is used for config keys that are mandatory.
@@ -364,16 +312,6 @@ private function getQueryStringOrFalse($configKey)
364312
return $this->getValueOrFalse($queryArray[$configKey] ?? false);
365313
}
366314

367-
/**
368-
* @param $dbType string db descriptor to check
369-
* @return bool whether the db is supported
370-
*/
371-
private function dbTypeIsSupported($dbType)
372-
{
373-
return $dbType === 'postgresql'
374-
|| $dbType === 'mariadb';
375-
}
376-
377315
/**
378316
* Checks whether hash algorithm is supported for writing.
379317
* @param $hashAlgorithm string hash algorithm descriptor to check
@@ -400,4 +338,5 @@ private function normalize($string)
400338
{
401339
return strtolower(preg_replace("/[-_]/", "", $string));
402340
}
341+
403342
}

0 commit comments

Comments
 (0)