├── .gitignore ├── EXTEND.md ├── README.md ├── composer.json ├── config.sample.yml ├── main ├── main-cli ├── src ├── Bridge │ ├── DatabaseBridge.php │ ├── HtpasswdBridge.php │ ├── HttpBridge.php │ ├── README.md │ └── SessionBridge.php ├── BridgeInterface.php ├── EjabberdAuth.php └── Plugin │ └── DrupalHttpBridge.php └── tests ├── test.php └── unit_test.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /config.yml 4 | -------------------------------------------------------------------------------- /EXTEND.md: -------------------------------------------------------------------------------- 1 | # Plugin creation 2 | 3 | There are currently two supported ways to bridge with a CMS: HTTP (via REST), and direct database access (via PDO). 4 | Alternatively, plugins may create an implementation from scratch, but these two base classes should cover most needs. 5 | 6 | ## REST 7 | 8 | The first solution requires a REST endpoint which supports a particular query format. 9 | 10 | This is the best solution if your target CMS provides an extendable REST API or uses a complex authentication 11 | mechanism that is not easy to duplicate. It is also required if your target CMS's database server cannot be 12 | directly accessed from the ejabberd server. 13 | 14 | ### Server side 15 | 16 | The REST API must support the following requests: 17 | 18 | ``` 19 | POST /endpoint 20 | { 21 | "command": "isuser", 22 | "user": "{$username}", 23 | "domain": "{$domain}" 24 | } 25 | 26 | POST /endpoint 27 | { 28 | "command": "auth", 29 | "user": "{$username}", 30 | "domain": "{$domain}", 31 | "password": "{$password}" 32 | } 33 | ``` 34 | 35 | The endpoint must return a response of `{"result": true}` with status code 200 if and only if the credentials are valid. 36 | 37 | ### Plugin class 38 | 39 | For reusability, you may hard-code your endpoint's path into a subclass of `HttpBridge`, though this is optional 40 | (you can also use `HttpBridge` directly, and give it the full URL including the endpoint). 41 | 42 | ```php 43 | namespace \Ermarian\EjabberdAuth\Plugin; 44 | 45 | use \Ermarian\EjabberdAuth\Bridge; 46 | 47 | class MyBridge extends HttpBridge { 48 | /* no initial slash */ 49 | protected static $endpoint = 'my/endpoint'; 50 | } 51 | ``` 52 | 53 | ### YAML file 54 | 55 | The plugin class is then specified in the config file (see `config.sample.yml`) as follows: 56 | 57 | ```yaml 58 | bridges: 59 | plugin1: 60 | class: '\Ermarian\EjabberdAuth\Plugin\MyBridge' 61 | config: 62 | url: 'http://example.com' 63 | hosts: 64 | - '*' 65 | #or 66 | plugin2: 67 | class: '\Ermarian\EjabberdAuth\Bridge\HttpBridge' 68 | config: 69 | url: 'http://example.com/my/endpoint' 70 | hosts: 71 | - '*' 72 | ``` 73 | 74 | ## Database 75 | 76 | The second method directly accesses the target system's database to check the credentials itself. 77 | 78 | This is a good solution if your target CMS has a fairly simple authentication mechanism, and its database 79 | server is exposed to the ejabberd server. 80 | 81 | [The code for this is not yet finished, and you may need to modify the DatabaseBridge class for your needs.] 82 | 83 | ### Plugin class 84 | 85 | You will need to create a subclass of `\Ermarian\EjabberdAuth\Bridge\DatabaseBridge` that implements four required methods: 86 | 87 | * `getUserQuery` 88 | * `getPasswordQuery` 89 | * `checkPassword` 90 | * `static create` 91 | 92 | The first three specify how to query the database and verify a password against a hash; the static `create()` function 93 | takes a config array (from the YAML) and sets up the database connection. It basically looks something like this: 94 | 95 | ```php 96 | namespace \Ermarian\EjabberdAuth\Plugin; 97 | 98 | use \Ermarian\EjabberdAuth\Bridge\DatabaseBridge; 99 | 100 | class MyBridge extends DatabaseBridge { 101 | /** 102 | * Create a plugin instance. 103 | * 104 | * @param array $config 105 | * The config array in the YAML file. 106 | */ 107 | public static function create(array $config) { 108 | return new static( 109 | new \PDO($config['host'], $config['user'], $config['password'] /* etc */); 110 | ); 111 | } 112 | 113 | public function getUserQuery(): \PDOStatement { 114 | // Create a user query. 115 | } 116 | 117 | public function getPasswordQuery(): \PDOStatement { 118 | // Create a password query. 119 | } 120 | 121 | public function checkPassword($username, $password, array $result) { 122 | // Check the username/password against the row record returned by the password query. 123 | // This is where you need to implement the hashing mechanism used by the CMS. 124 | } 125 | } 126 | ``` 127 | 128 | ### YAML file 129 | 130 | ```yaml 131 | bridges: 132 | plugin1: 133 | class: '\xzy\yourclass' 134 | config: 135 | host: '' 136 | user: '' 137 | password: '' 138 | database: '' 139 | // (plus any other configurable stuff) 140 | hosts: 141 | - '*' 142 | ``` 143 | 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ejabberd-auth-php 2 | ================= 3 | 4 | This is a collection of bridges allowing an ejabberd XMPP server to use a locally 5 | installed PHP-based CMS for external authentication. 6 | 7 | Features 8 | -------- 9 | 10 | Currently implemented bridges: 11 | 12 | * Drupal 8 13 | * Apache htpasswd 14 | 15 | Installation 16 | ------------ 17 | 18 | Copy the file `config.sample.yml` to `config.yml` and fill in the appropriate 19 | values. 20 | 21 | Open your ejabberd configuration and set the external authentication script: 22 | 23 | ### ejabberd < 13.10 ### 24 | 25 | The configuration file should be located at `/etc/ejabberd/ejabberd.cfg`. Find, uncomment 26 | and edit the following lines. 27 | 28 | {auth_method, external}. 29 | {extauth_program, ".../ejabberd-auth-php/main"}. 30 | 31 | ### ejabberd 13.10+ ### 32 | 33 | The configuration file is at `/etc/ejabberd/ejabberd.yml`. 34 | 35 | auth_method: external 36 | extauth_program: ".../ejabberd-auth-php/main" 37 | 38 | License 39 | ------- 40 | 41 | The core project, without plugins, may be distributed or modified 42 | under the under the terms of the MIT license. 43 | 44 | The `drupal` plugin contains a module that interfaces with the 45 | [Drupal](https://drupal.org/) project and is licensed under the 46 | GNU General Public License, version 2 or later. 47 | 48 | Support 49 | ------- 50 | 51 | I will not be able to offer support or reliable maintenance for this software, 52 | or any of its plugins. Functionality may be changed without notice. This software 53 | is (for now) indefinitely in pre-release mode, and there are no current plans 54 | for a stable release. 55 | 56 | Your best bet for using this software is to fork it and maintain your own 57 | codebase. I will gladly take pull requests under consideration if you feel like 58 | contributing. 59 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ermarian/ejabberd-auth", 3 | "description": "Bridging ejabberd with PHP authentication systems.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Christoph Burschka", 9 | "email": "christoph@burschka.de" 10 | } 11 | ], 12 | "require": { 13 | "php-curl-class/php-curl-class": "^7.3", 14 | "symfony/yaml": "^3.3" 15 | }, 16 | "autoload": { 17 | "psr-4": { 18 | "Ermarian\\EjabberdAuth\\": "src" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /config.sample.yml: -------------------------------------------------------------------------------- 1 | log_path: '' 2 | bridges: 3 | default: 4 | class: '\Ermarian\EjabberdAuth\Bridge\HttpBridge' 5 | parameters: 6 | url: 'https://example.com/' 7 | hosts: 8 | - '*.example.com' 9 | - 'example.*' 10 | - 'example.com' 11 | - '.com' 12 | - '*' 13 | htpasswd: 14 | class: '\Ermarian\EjabberdAuth\Bridge\HtpasswdBridge' 15 | parameters: 16 | # Paths are relative to package root. 17 | file: 'htpasswd.txt' 18 | hosts: 19 | - 'htpasswd.example.com' 20 | -------------------------------------------------------------------------------- /main: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 12 | } 13 | catch (\Exception $exception) { 14 | file_put_contents('php://stderr', $exception->getMessage() . "\n"); 15 | exit(1); 16 | } 17 | -------------------------------------------------------------------------------- /main-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | execute($args); 17 | print($result ? 'true' : 'false'); 18 | print("\n"); 19 | exit($result ? 0 : 1); 20 | } 21 | catch (\Exception $exception) { 22 | file_put_contents('php://stderr', $exception->getMessage() . "\n"); 23 | exit(1); 24 | } 25 | } 26 | else { 27 | print( 28 | 'Usage: 29 | main-cli isuser 30 | main-cli auth 31 | '); 32 | } 33 | -------------------------------------------------------------------------------- /src/Bridge/DatabaseBridge.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 32 | $this->userQuery = $this->connection->prepare($this->getUserQuery()); 33 | $this->passwordQuery = $this->connection->prepare($this->getPasswordQuery()); 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function isuser($username, $server) { 40 | return $this->userQuery->execute(); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function auth($username, $server, $password) { 47 | $this->passwordQuery->execute([static::USERNAME => $username]); 48 | $result = $this->passwordQuery->fetch(\PDO::FETCH_ASSOC); 49 | return $this->checkPassword($username, $password, $result); 50 | } 51 | 52 | /** 53 | * Construct the PDO statement. 54 | * 55 | * @return string 56 | */ 57 | abstract protected function getUserQuery(); 58 | 59 | /** 60 | * Construct the PDO statement. 61 | * 62 | * @return string 63 | */ 64 | abstract protected function getPasswordQuery(); 65 | 66 | /** 67 | * Check a username's password against a result from the database. 68 | * 69 | * @param string $username 70 | * @param string $password 71 | * @param array $result 72 | * 73 | * @return bool 74 | */ 75 | abstract protected function checkPassword($username, $password, array $result); 76 | } 77 | -------------------------------------------------------------------------------- /src/Bridge/HtpasswdBridge.php: -------------------------------------------------------------------------------- 1 | data = $data; 31 | $this->plain = $plain; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | * 37 | * @throws \InvalidArgumentException 38 | */ 39 | public static function create(array $config) { 40 | $data = []; 41 | $file = ROOT . $config['file']; 42 | if (file_exists($file) && is_readable($file)) { 43 | $lines = explode("\n", trim(file_get_contents($file))); 44 | foreach ($lines as $line) { 45 | list($user, $password) = explode(':', trim($line), 2); 46 | $data[$user] = $password; 47 | } 48 | } 49 | else { 50 | throw new \InvalidArgumentException("htpasswd file $file cannot be read."); 51 | } 52 | 53 | return new static( 54 | $data, 55 | !empty($config['plain']) 56 | ); 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function isuser($username, $server) { 63 | return array_key_exists($username, $this->data); 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public function auth($username, $server, $password) { 70 | return $this->isuser($username, $server) && static::check($password, $this->data[$username], $this->plain); 71 | } 72 | 73 | /** 74 | * Check a password against a hash value. 75 | * 76 | * @param string $clear 77 | * @param string $hash 78 | * @param bool $plain 79 | * Whether to interpret a format-less hash as DES-crypt or plain. 80 | * 81 | * @return bool 82 | */ 83 | protected static function check($clear, $hash, $plain = FALSE) { 84 | /* htpasswd supports the following hashing methods: 85 | * - MD5 (standard) 86 | * - blowfish 87 | * - crypt (DES) 88 | * - sha1 89 | * - plain 90 | * 91 | * All but the Apache-specific MD5 implementation 92 | * are available in PHP. 93 | */ 94 | 95 | if (preg_match('/^\$apr1\$(.*?)\$.*$/', $hash, $match)) { 96 | $result = static::apr_md5($clear, $match[1]); 97 | } 98 | elseif (preg_match('/^\$2y\$.*$/', $hash, $match)) { 99 | $result = crypt($clear, $match[0]); 100 | } 101 | elseif (preg_match('/^\{SHA\}.*$/', $hash, $match)) { 102 | $result = '{SHA}' . base64_encode(sha1($clear, TRUE)); 103 | } 104 | 105 | // The crypt and clear formats are not distinguishable. 106 | elseif ($plain) { 107 | $result = $clear; 108 | } 109 | else { 110 | $result = crypt($clear, $hash); 111 | } 112 | 113 | return hash_equals($result, $hash); 114 | } 115 | 116 | /** 117 | * Parts of this APR-MD5 implementation are derived from 118 | * an example at http://php.net/crypt 119 | * 120 | * @param string $clear 121 | * @param string $salt 122 | * 123 | * @return string 124 | */ 125 | protected static function apr_md5($clear, $salt) { 126 | $len = strlen($clear); 127 | $text = $clear . '$apr1$' . $salt; 128 | $bin = pack('H32', md5($clear . $salt . $clear)); 129 | for ($i = $len; $i > 0; $i -= 16) { 130 | $text .= substr($bin, 0, min(16, $i)); 131 | } 132 | for ($i = $len; $i > 0; $i >>= 1) { 133 | $text .= ($i & 1) ? chr(0) : $clear{0}; 134 | } 135 | $bin = pack('H32', md5($text)); 136 | 137 | for ($i = 0; $i < 1000; $i++) { 138 | $new = ($i & 1) ? $clear : $bin; 139 | if ($i % 3) { 140 | $new .= $salt; 141 | } 142 | if ($i % 7) { 143 | $new .= $clear; 144 | } 145 | $new .= ($i & 1) ? $bin : $clear; 146 | $bin = pack('H32', md5($new)); 147 | } 148 | 149 | $tmp = ''; 150 | for ($i = 0; $i < 5; $i++) { 151 | $k = $i + 6; 152 | $j = $i + 12; 153 | if ($j === 16) { 154 | $j = 5; 155 | } 156 | $tmp = $bin[$i] . $bin[$k] . $bin[$j] . $tmp; 157 | } 158 | 159 | $tmp = chr(0) . chr(0) . $bin[11] . $tmp; 160 | $tmp = strtr(strrev(substr(base64_encode($tmp), 2)), 161 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 162 | './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'); 163 | return '$apr1$' . $salt . '$' . $tmp; 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/Bridge/HttpBridge.php: -------------------------------------------------------------------------------- 1 | url = $url; 29 | } 30 | 31 | /** 32 | * Factory method for initializing the plugin from config. 33 | * 34 | * @param array $config 35 | * 36 | * @return static 37 | */ 38 | public static function create(array $config) { 39 | $url = $config['url']; 40 | if ($endpoint = static::$endpoint) { 41 | $url = rtrim($url, '/') . '/' . $endpoint; 42 | } 43 | return new static($url); 44 | } 45 | 46 | /** 47 | * Check whether a user exists. 48 | * 49 | * @param string $username 50 | * @param string $server 51 | * 52 | * @return bool 53 | * 54 | * @throws \ErrorException 55 | */ 56 | public function isuser($username, $server) { 57 | $request = new Curl($this->url); 58 | $response = $request->post([ 59 | 'command' => 'isuser', 60 | 'user' => $username, 61 | 'domain' => $server, 62 | ]); 63 | return $response && $response->result === TRUE; 64 | } 65 | 66 | /** 67 | * Authenticate a user. 68 | * 69 | * @param string $username 70 | * @param string $server 71 | * @param string $password 72 | * 73 | * @return bool 74 | * 75 | * @throws \ErrorException 76 | */ 77 | public function auth($username, $server, $password) { 78 | $request = new Curl($this->url); 79 | $response = $request->post([ 80 | 'command' => 'auth', 81 | 'user' => $username, 82 | 'password'=> $password, 83 | 'domain' => $server, 84 | ]); 85 | return $response->result === TRUE; 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/Bridge/README.md: -------------------------------------------------------------------------------- 1 | htpasswd 2 | ======== 3 | 4 | This plugin can parse an Apache authentication file 5 | generated by htpasswd. 6 | 7 | The following hash types are supported: 8 | 9 | - APR-MD5 (`htpasswd [-m]`, default method) 10 | - SHA1 (`htpasswd -s`) 11 | - Blowfish (`htpasswd -B`) 12 | - DES (`htpasswd -d`) 13 | - Plaintext (`htpasswd -s`) 14 | 15 | Note that DES and Plaintext are mutually exclusive, because 16 | the format is not readily distinguishable. Any hash that does 17 | not match the MD5, SHA1 or Blowfish formats will be treated as 18 | a DES hash or a plaintext password depending on configuration. 19 | 20 | Installation 21 | ------------ 22 | 23 | This configuration must be entered into plugin_conf in config.php: 24 | 25 | 'plugin_conf' => [ 26 | 'htpasswd_file' => '', 27 | 'plain' => FALSE, // optional 28 | ] 29 | 30 | Each domain can use a separate file, as in: 31 | 32 | 'plugin_conf' => [ 33 | 'htpasswd_file' => [ 34 | 'example.com' => '', 35 | 'example.org' => '', 36 | ] 37 | ] 38 | -------------------------------------------------------------------------------- /src/Bridge/SessionBridge.php: -------------------------------------------------------------------------------- 1 | install($pdo, $table); 47 | $this->_insert = $pdo->prepare("INSERT INTO `{$table}` (`username`, `secret`, `created`) VALUES (:username, :secret, :created);"); 48 | $this->_isuser = $pdo->prepare("SELECT COUNT(*) FROM `{$table}` WHERE `username` = :user AND `created` >= :limit;"); 49 | $this->_auth = $pdo->prepare("DELETE FROM `{$table}` WHERE `username` = :user AND `secret` = :secret AND `created` >= :limit;"); 50 | $this->_prune = $pdo->prepare("DELETE FROM `{$table}` WHERE `created` < :limit;"); 51 | $this->timeout = $timeout ?: 60; 52 | } 53 | 54 | /** 55 | * {@inheritdoc} 56 | */ 57 | public static function create(array $config) { 58 | $mysql = $config['mysql']; 59 | $options = [\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8']; 60 | return new static( 61 | new \PDO($mysql['dsn'], $mysql['username'], $mysql['password'], $options), 62 | $mysql['tablename'], 63 | $config['timeout'] 64 | ); 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function prune() { 71 | $this->_prune->execute([':limit' => time() - $this->timeout]); 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | public function isuser($username, $server) { 78 | $this->prune(); 79 | $this->_isuser->execute([ 80 | ':user' => $username, 81 | ':limit' => time() - $this->timeout, 82 | ]); 83 | return $this->_isuser->fetch()[0] > 0; 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function auth($username, $server, $password) { 90 | $this->prune(); 91 | $this->_auth->execute([ 92 | ':user' => $username, 93 | ':secret' => $password, 94 | ':limit' => time() - $this->timeout, 95 | ]); 96 | return $this->_auth->rowCount() > 0; 97 | } 98 | 99 | /** 100 | * Insert a new temporary secret. 101 | * 102 | * @param string $username 103 | * @param string $server 104 | * 105 | * @return string 106 | * The secret hash. 107 | */ 108 | public function insert($username, $server) { 109 | $secret = sha1("$username@$server:" . microtime() . random_bytes(16)); 110 | return !$this->_insert->execute([ 111 | ':user' => $username, 112 | ':secret' => $secret, 113 | ':created' => time(), 114 | ]) ?: $secret; 115 | } 116 | 117 | /** 118 | * Ensure the table exists. 119 | * 120 | * @param \PDO $database 121 | * @param string $table 122 | */ 123 | private function install(\PDO $database, $table) { 124 | if ($database->exec("SELECT 1 FROM `{$table}`;") === FALSE) { 125 | $database->exec("CREATE TABLE `{$table}` ( 126 | username VARCHAR(255), 127 | secret VARCHAR(40), 128 | created INT, 129 | PRIMARY KEY(username, secret), 130 | INDEX(created))"); 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/BridgeInterface.php: -------------------------------------------------------------------------------- 1 | bridges = $bridges; 42 | $this->routes = $routes; 43 | if ($log_path && is_dir($log_path) && is_writable($log_path)) { 44 | $date = date('Y-m-d'); 45 | $filename = "{$log_path}/activity-{$date}.log"; 46 | $this->logfile = fopen($filename, 'ab'); 47 | } 48 | else { 49 | $this->logfile = STDERR; 50 | } 51 | $this->log('Initialized.'); 52 | } 53 | 54 | public static function create(array $config) { 55 | $bridges = []; 56 | $routes = []; 57 | foreach ((array) $config['bridges'] as $key => $bridge) { 58 | $callable = [$bridge['class'], 'create']; 59 | $parameters = $bridge['parameters']; 60 | $bridges[$key] = $callable($parameters); 61 | foreach ((array) $bridge['hosts'] as $pattern) { 62 | $score = ($pattern[0] !== '.') 63 | + substr_count($pattern, '.') 64 | - substr_count($pattern, '*'); 65 | $regex = str_replace(['.', '*'], ['\.', '[a-z0-9-]*'], $pattern); 66 | if ($pattern[0] === '.') { 67 | $regex = '/^.*?' . substr($regex, 2) . '$/'; 68 | } 69 | else { 70 | $regex = "/^$regex$/"; 71 | } 72 | $routes[] = [ 73 | 'score' => $score, 74 | 'pattern' => $pattern, 75 | 'regex' => $regex, 76 | 'key' => $key 77 | ]; 78 | } 79 | } 80 | usort($routes, function ($a, $b) { 81 | return $b['score'] - $a['score']; 82 | }); 83 | 84 | return new static( 85 | $bridges, 86 | $routes, 87 | $config['log_path'] 88 | ); 89 | } 90 | 91 | /** 92 | * @param string $filename 93 | * 94 | * @return \Ermarian\EjabberdAuth\EjabberdAuth 95 | * 96 | * @throws \InvalidArgumentException 97 | * @throws \Symfony\Component\Yaml\Exception\ParseException 98 | */ 99 | public static function createFromFile($filename) { 100 | if (!is_file($filename)) { 101 | throw new \InvalidArgumentException("Configuration file {$filename} does not exist."); 102 | } 103 | $config = Yaml::parse(file_get_contents($filename)); 104 | return static::create($config); 105 | } 106 | 107 | /** 108 | * Stop the process. 109 | */ 110 | public function stop() { 111 | $this->log('Stopping...'); 112 | $this->running = FALSE; 113 | } 114 | 115 | /** 116 | * Run this process. 117 | * 118 | * Blocks until ::stop() is called or STDIN closes. 119 | */ 120 | public function run() { 121 | $this->log('Starting...'); 122 | $this->running = TRUE; 123 | while ($this->running && $data = $this->read()) { 124 | if ($data) { 125 | try { 126 | $result = $this->execute($data); 127 | $this->write((int)$result); 128 | } 129 | catch (\InvalidArgumentException $exception) { 130 | $this->log($exception->getMessage()); 131 | $this->write(0); 132 | } 133 | } 134 | } 135 | $this->log('Stopped'); 136 | } 137 | 138 | /** 139 | * Read a command from the input stream. 140 | * 141 | * Blocks until STDIN provides data or closes. 142 | * 143 | * @return string|null 144 | */ 145 | public function read() { 146 | $input = fread(STDIN, 2); 147 | if ($input) { 148 | $input = unpack('n', $input); 149 | $length = $input[1]; 150 | if($length > 0) { 151 | $this->log("Reading $length bytes..."); 152 | return fread(STDIN, $length); 153 | } 154 | } 155 | } 156 | 157 | /** 158 | * Write a command to the output stream. 159 | * 160 | * @param $data 161 | */ 162 | public function write($data) { 163 | $this->log("OUT: $data"); 164 | fwrite(STDOUT, pack('nn', 2, $data)); 165 | } 166 | 167 | /** 168 | * Log an event. 169 | * 170 | * @param string $message 171 | */ 172 | public function log($message) { 173 | $entry = sprintf("%s [%d] - %s\n", date('Y-m-d H:i:s'), getmypid(), $message); 174 | fwrite($this->logfile, $entry); 175 | } 176 | 177 | /** 178 | * Execute a command from the server. 179 | * 180 | * @param $data 181 | * 182 | * @return bool 183 | * @throws \InvalidArgumentException 184 | */ 185 | public function execute($data) { 186 | $args = is_array($data) ? array_merge($data, [NULL,NULL,NULL]) : explode(':', $data . ':::'); 187 | list($command, $username, $server, $password) = $args; 188 | $username = static::decodeJidNode($username); 189 | 190 | // Don't log the password, obviously. 191 | $this->log("Executing $command on {$username}@{$server}"); 192 | 193 | switch ($command) { 194 | case 'isuser': 195 | return $this->getBridge($server)->isuser($username, $server); 196 | case 'auth': 197 | return $this->getBridge($server)->auth($username, $server, $password); 198 | case 'setpass': 199 | case 'tryregister': 200 | case 'removeuser': 201 | case 'removeuser3': 202 | return FALSE; 203 | default: 204 | $this->stop(); 205 | } 206 | } 207 | 208 | /** 209 | * Match a hostname to the configured routes. 210 | * 211 | * @param $server 212 | * 213 | * @return \Ermarian\EjabberdAuth\BridgeInterface 214 | * 215 | * @throws \InvalidArgumentException 216 | */ 217 | protected function getBridge($server) { 218 | if (!isset($this->bridgeCache[$server])) { 219 | $result = FALSE; 220 | foreach ($this->routes as $route) { 221 | if (preg_match($route['regex'], $server)) { 222 | $this->log("Matched {$server} to {$route['pattern']} for {$route['key']}."); 223 | $result = $this->bridges[$route['key']]; 224 | break; 225 | } 226 | } 227 | $this->bridgeCache[$server] = $result; 228 | if (!$result) { 229 | throw new \InvalidArgumentException("Unknown host '{$server}'"); 230 | } 231 | } 232 | 233 | return $this->bridgeCache[$server]; 234 | } 235 | 236 | public static function decodeJidNode($string) { 237 | return str_replace( 238 | ['\\20', '\\22', '\\26', '\\27', '\\2f', '\\3a', '\\3c', '\\3e', '\\40', '\\5c'], 239 | [' ', '"', '&', '\'', '/', ':', '<', '>', '@', '\\'], 240 | $string); 241 | } 242 | } 243 | 244 | -------------------------------------------------------------------------------- /src/Plugin/DrupalHttpBridge.php: -------------------------------------------------------------------------------- 1 | 8 | '); 9 | } 10 | 11 | $valid_account = [ 12 | 'user' => $_SERVER['argv'][1], 13 | 'domain' => $_SERVER['argv'][2], 14 | 'password' => $_SERVER['argv'][3], 15 | ]; 16 | 17 | require_once __DIR__ . '/unit_test.php'; 18 | 19 | (new UnitTest($valid_account))->run(); 20 | -------------------------------------------------------------------------------- /tests/unit_test.php: -------------------------------------------------------------------------------- 1 | ['pipe', 'r'], // stdin is a pipe that the child will read from 7 | 1 => ['pipe', 'w'], // stdout is a pipe that the child will write to 8 | 2 => STDERR, // forward stderr directly 9 | ]; 10 | $cmd = __DIR__ . '/../main'; 11 | $this->_process = proc_open($cmd, $spec, $this->pipes); 12 | $this->_input = $this->pipes[0]; 13 | $this->_output = $this->pipes[1]; 14 | $this->valid = $valid_account; 15 | $this->successes = 0; 16 | $this->failures = 0; 17 | $this->cases = 0; 18 | } 19 | 20 | function run() { 21 | foreach (get_class_methods($this) as $method) { 22 | if (strpos($method, 'Test') === 0) { 23 | $this->$method(); 24 | } 25 | } 26 | printf("%d tests, %d passed, %d failed\n", $this->cases, $this->successes, $this->failures); 27 | } 28 | 29 | function _send_request($request) { 30 | $request = implode(':', $request); 31 | fwrite($this->_input, pack('n', strlen($request))); 32 | fwrite($this->_input, $request); 33 | $result = unpack('n2x', fread($this->_output, 4)); 34 | return [$result['x1'], $result['x2']]; 35 | } 36 | 37 | function assert($request, $success, $comment) { 38 | $result = $this->_send_request($request); 39 | if ($result[0] == 2 and $result[1] == $success) { 40 | $this->successes++; 41 | printf("\033[0;32mPASS #%d: %s\033[0m\n", 1+$this->cases, $comment); 42 | } 43 | else { 44 | $this->failures++; 45 | printf("\033[0;31mFAIL #%d: %s\033[0m\n", 1+$this->cases, $comment); 46 | } 47 | $this->cases++; 48 | } 49 | 50 | function TestUserGood() { 51 | $this->assert(['isuser', $this->valid['user'], $this->valid['domain']], TRUE, 'isuser with valid username'); 52 | } 53 | 54 | function TestUserBad() { 55 | $this->assert(['isuser', '123456789', $this->valid['domain']], FALSE, 'isuser with bad username'); 56 | } 57 | 58 | function TestAuthGood() { 59 | $this->assert(['auth', $this->valid['user'], $this->valid['domain'], $this->valid['password']], TRUE, 'auth with valid password'); 60 | } 61 | 62 | function TestAuthBadUser() { 63 | $this->assert(['auth', '123456789', $this->valid['domain'], '123456789'], FALSE, 'auth with bad username'); 64 | } 65 | 66 | function TestAuthBadPass() { 67 | $this->assert(['auth', $this->valid['user'], $this->valid['domain'], '123456789'], FALSE, 'auth with bad password'); 68 | } 69 | 70 | function TestSetPass() { 71 | $this->assert(['setpass', '123456789', $this->valid['domain'], '123456789'], FALSE, 'attempt to set password (fail)'); 72 | } 73 | 74 | function TestRegister() { 75 | $this->assert(['tryregister', '123456789', $this->valid['domain'], '123456789'], FALSE, 'attempt to create account (fail)'); 76 | } 77 | 78 | function TestRemove() { 79 | $this->assert(['removeuser', '123456789', $this->valid['domain'], '123456789'], FALSE, 'attempt to delete account (fail)'); 80 | } 81 | 82 | function TestRemove3() { 83 | $this->assert(['removeuser3', '123456789', $this->valid['domain'], '123456789'], FALSE, 'attempt to login and delete account (fail)'); 84 | } 85 | } 86 | --------------------------------------------------------------------------------