1<?php
2
3namespace dokuwiki\plugin\pureldap\classes;
4
5use dokuwiki\ErrorHandler;
6use dokuwiki\Logger;
7use dokuwiki\Utf8\Clean;
8use dokuwiki\Utf8\PhpString;
9use FreeDSx\Ldap\Entry\Attribute;
10use FreeDSx\Ldap\Exception\BindException;
11use FreeDSx\Ldap\Exception\ConnectionException;
12use FreeDSx\Ldap\Exception\OperationException;
13use FreeDSx\Ldap\LdapClient;
14
15abstract class Client
16{
17    public const FILTER_EQUAL = 'equal';
18    public const FILTER_CONTAINS = 'contains';
19    public const FILTER_STARTSWITH = 'startsWith';
20    public const FILTER_ENDSWITH = 'endsWith';
21
22    /** @var array the configuration */
23    protected $config;
24
25    /** @var LdapClient */
26    protected $ldap;
27
28    /** @var bool|string is this client authenticated already? Contains username if yes */
29    protected $isAuthenticated = false;
30
31    /** @var array cached user info */
32    protected $userCache = [];
33
34    /** @var array cached group list */
35    protected $groupCache = [];
36
37    /**
38     * Client constructor.
39     * @param array $config
40     */
41    public function __construct($config)
42    {
43        require_once __DIR__ . '/../vendor/autoload.php';
44
45        $this->config = $this->prepareConfig($config);
46        $this->prepareSSO();
47        $this->ldap = new LdapClient($this->config);
48    }
49
50    /**
51     * Setup sane config defaults
52     *
53     * @param array $config
54     * @return array
55     */
56    protected function prepareConfig($config)
57    {
58        // ensure we have the default keys
59        /** @var array $conf */
60        include __DIR__ . '/../conf/default.php';
61        $defaults = $conf;
62        $defaults['defaultgroup'] = 'user'; // we expect this to be passed from global conf
63
64        $config = array_merge($defaults, $config);
65
66        // default port depends on SSL setting
67        if (!$config['port']) {
68            $config['port'] = ($config['encryption'] === 'ssl') ? 636 : 389;
69        }
70
71        // set ssl parameters
72        $config['use_ssl'] = ($config['encryption'] === 'ssl');
73        if ($config['validate'] === 'none') {
74            $config['ssl_validate_cert'] = false;
75        } elseif ($config['validate'] === 'self') {
76            $config['ssl_allow_self_signed'] = true;
77        }
78
79        $config['suffix'] = ltrim(PhpString::strtolower($config['suffix']), '@');
80        $config['primarygroup'] = $this->cleanGroup($config['primarygroup']);
81
82        return $config;
83    }
84
85    /**
86     * Extract user info from environment for SSO
87     */
88    protected function prepareSSO()
89    {
90        global $INPUT;
91
92        if (!$this->config['sso']) return;
93        if ($INPUT->server->str('REMOTE_USER') === '') return;
94
95        $user = $INPUT->server->str('REMOTE_USER');
96
97        // make sure the right encoding is used
98        if ($this->config['sso_charset']) {
99            if (function_exists('iconv')) {
100                $user = iconv($this->config['sso_charset'], 'UTF-8', $user);
101            } elseif (function_exists('mb_convert_encoding')) {
102                $user = mb_convert_encoding($user, 'UTF-8', $this->config['sso_charset']);
103            }
104        } elseif (!Clean::isUtf8($user)) {
105            $user = utf8_encode($user);
106        }
107        $user = $this->cleanUser($user);
108
109        // Prepare SSO
110
111        // trust the incoming user
112        $INPUT->server->set('REMOTE_USER', $user);
113
114        // we need to simulate a login
115        if (empty($_COOKIE[DOKU_COOKIE])) {
116            $INPUT->set('u', $user);
117            $INPUT->set('p', 'sso_only');
118        }
119    }
120
121    /**
122     * Access to the config values
123     *
124     * Because the client class does configuration cleanup, the auth class should access
125     * config values through the client
126     *
127     * @param string $key
128     * @return mixed returns null on missing config
129     */
130    public function getConf($key)
131    {
132        if (!isset($this->config[$key])) return null;
133        return $this->config[$key];
134    }
135
136    /**
137     * Authenticate as admin if not authenticated yet
138     */
139    public function autoAuth()
140    {
141        if ($this->isAuthenticated) return true;
142
143        $user = $this->prepareBindUser($this->config['admin_username']);
144        try {
145            $this->authenticate($user, $this->config['admin_password']);
146            return true;
147        } catch (\Exception $e) {
148            $this->error('Automatic bind failed. Probably wrong user/password.', __FILE__, __LINE__);
149        }
150        return false;
151    }
152
153    /**
154     * Authenticates a given user. This client will remain authenticated
155     *
156     * @param string $user
157     * @param string $pass
158     * @noinspection PhpRedundantCatchClauseInspection
159     * @return true
160     * @throws ConnectionException
161     * @throws OperationException
162     * @throws BindException
163     */
164    public function authenticate($user, $pass)
165    {
166        $user = $this->prepareBindUser($user);
167        $this->isAuthenticated = false;
168
169        if (!$this->ldap->isConnected() && $this->config['encryption'] === 'tls') {
170            try {
171                $this->ldap->startTls();
172            } catch (ConnectionException|OperationException $e) {
173                $this->fatal($e);
174                throw $e;
175            }
176        }
177
178        try {
179            $this->ldap->bind($user, $pass);
180        } catch (BindException $e) {
181            $this->debug("Bind for $user failed: " . $e->getMessage(), $e->getFile(), $e->getLine());
182            throw $e;
183        } catch (ConnectionException|OperationException $e) {
184            $this->fatal($e);
185            throw $e;
186        }
187
188        $this->isAuthenticated = $user;
189        return true;
190    }
191
192    /**
193     * Get info for a single user, use cache if available
194     *
195     * @param string $username
196     * @param bool $fetchgroups Are groups needed?
197     * @return array|null
198     */
199    public function getCachedUser($username, $fetchgroups = true)
200    {
201        global $conf;
202
203        // memory cache first
204        if (isset($this->userCache[$username])) {
205            if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) {
206                return $this->userCache[$username];
207            }
208        }
209
210        // disk cache second
211        if ($this->config['usefscache']) {
212            $cachename = getCacheName($username, '.pureldap-user');
213            $cachetime = @filemtime($cachename);
214            if ($cachetime && (time() - $cachetime) < $conf['auth_security_timeout']) {
215                $this->userCache[$username] = json_decode(
216                    file_get_contents($cachename),
217                    true,
218                    512,
219                    JSON_THROW_ON_ERROR
220                );
221                if (!$fetchgroups || is_array($this->userCache[$username]['grps'])) {
222                    return $this->userCache[$username];
223                }
224            }
225        }
226
227        // fetch fresh data
228        $info = $this->getUser($username, $fetchgroups);
229
230        // store in cache
231        if ($this->config['usefscache'] && $info !== null) {
232            $this->userCache[$username] = $info;
233            /** @noinspection PhpUndefinedVariableInspection We know that cachename is defined */
234            file_put_contents($cachename, json_encode($info, JSON_THROW_ON_ERROR));
235        }
236
237        return $info;
238    }
239
240    /**
241     * Fetch a single user
242     *
243     * @param string $username
244     * @param bool $fetchgroups Shall groups be fetched, too?
245     * @return null|array
246     */
247    abstract public function getUser($username, $fetchgroups = true);
248
249    /**
250     * Set a new password for a user
251     *
252     * @param string $username
253     * @param string $newpass
254     * @param string $oldpass Needed for self-service password change in AD
255     * @return bool
256     */
257    abstract public function setPassword($username, $newpass, $oldpass = null);
258
259    /**
260     * Return a list of all available groups, use cache if available
261     *
262     * @return string[]
263     */
264    public function getCachedGroups()
265    {
266        if (empty($this->groupCache)) {
267            $this->groupCache = $this->getGroups();
268        }
269
270        return $this->groupCache;
271    }
272
273    /**
274     * Return a list of all available groups
275     *
276     * Optionally filter the list
277     *
278     * @param null|string $match Filter for this, null for all groups
279     * @param string $filtermethod How to match the groups
280     * @return string[]
281     */
282    abstract public function getGroups($match = null, $filtermethod = self::FILTER_EQUAL);
283
284    /**
285     * Clean the user name for use in DokuWiki
286     *
287     * @param string $user
288     * @return string
289     */
290    abstract public function cleanUser($user);
291
292    /**
293     * Clean the group name for use in DokuWiki
294     *
295     * @param string $group
296     * @return string
297     */
298    abstract public function cleanGroup($group);
299
300    /**
301     * Inheriting classes may want to manipulate the user before binding
302     *
303     * @param string $user
304     * @return string
305     */
306    protected function prepareBindUser($user)
307    {
308        return $user;
309    }
310
311    /**
312     * Helper method to get the first value of the given attribute
313     *
314     * The given attribute may be null, an empty string is returned then
315     *
316     * @param Attribute|null $attribute
317     * @return string
318     */
319    protected function attr2str($attribute)
320    {
321        if ($attribute !== null) {
322            return $attribute->firstValue() ?? '';
323        }
324        return '';
325    }
326
327    /**
328     * Get the attributes that should be fetched for a user
329     *
330     * Can be extended in sub classes
331     *
332     * @return Attribute[]
333     */
334    protected function userAttributes()
335    {
336        // defaults
337        $attr = [
338            new Attribute('dn'),
339            new Attribute('displayName'),
340            new Attribute('mail'),
341        ];
342        // additionals
343        foreach ($this->config['attributes'] as $attribute) {
344            $attr[] = new Attribute($attribute);
345        }
346        return $attr;
347    }
348
349    /**
350     * Get the maximum age a password may have before it needs to be changed
351     *
352     * @return int 0 if no maximum age is set
353     */
354    public function getMaxPasswordAge()
355    {
356        return 0;
357    }
358
359    /**
360     * Handle fatal exceptions
361     *
362     * @param \Exception $e
363     */
364    protected function fatal(\Exception $e)
365    {
366        ErrorHandler::logException($e);
367
368        if (defined('DOKU_UNITTEST')) {
369            throw new \RuntimeException('', 0, $e);
370        }
371
372        msg('[pureldap] ' . hsc($e->getMessage()), -1);
373    }
374
375    /**
376     * Handle error output
377     *
378     * @param string $msg
379     * @param string $file
380     * @param int $line
381     */
382    public function error($msg, $file, $line)
383    {
384        Logger::error('[pureldap] ' . $msg, '', $file, $line);
385
386        if (defined('DOKU_UNITTEST')) {
387            throw new \RuntimeException($msg . ' at ' . $file . ':' . $line);
388        }
389
390        msg('[pureldap] ' . hsc($msg), -1);
391    }
392
393    /**
394     * Handle debug output
395     *
396     * @param string $msg
397     * @param string $file
398     * @param int $line
399     */
400    public function debug($msg, $file, $line)
401    {
402        global $conf;
403
404        Logger::debug('[pureldap] ' . $msg, '', $file, $line);
405
406        if ($conf['allowdebug']) {
407            msg('[pureldap] ' . hsc($msg) . ' at ' . $file . ':' . $line);
408        }
409    }
410}
411