1<?php
2/**
3 * DokuWiki Plugin authyubikey (Auth Component)
4 * Plaintext authentication backend combined with Yubico's OTP
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Dirk Scheer <dirk@scheernet.de>
8 */
9
10// This lib is developed by Yubico.
11// Take a look at https://developers.yubico.com/php-yubico/
12require_once 'lib/Yubico.php';
13
14// must be run within Dokuwiki
15if(!defined('DOKU_INC')) die();
16
17/*
18 * Class auth_plugin_authyubikey simply extends the basic
19 * auth_plugin_authplain class definition by Andreas Gohr
20 * <andi@splitbrain.org>.
21 */
22class auth_plugin_authyubikey extends auth_plugin_authplain {
23    /**
24     * Constructor
25     *
26     * Carry out sanity checks to ensure the object is
27     * able to operate. Set capabilities.
28     *
29     * @author  Dirk Scheer <dirk@scheernet.de>
30     */
31    public function __construct() {
32        parent::__construct();
33    }
34
35    /**
36     * Check user+password
37     *
38     * Checks if the given user exists and the given
39     * plaintext password is correct
40     *
41     * @author  Dirk Scheer <dirk@scheernet.de>
42     * @param string $user
43     * @param string $pass
44     * @return  bool
45     */
46    public function checkPass($user, $pass) {
47        global $INPUT;
48        global $config;
49
50        /* Get all defined users with their attributes */
51        $userinfo = $this->getUserData($user);
52        if($userinfo === false) return false;
53
54        /* Check the given password */
55        if(auth_verifyPassword($pass, $this->users[$user]['pass']) === false) return false;
56
57        /* If this function is called in another context as the login form;
58         * then checking of the password is enough.
59         * (I hope, this is not a security risc!!!)
60         */
61        if($INPUT->str('do') !== 'login') {
62            return true;
63        }
64
65        /* Get the yubikey IDs of the user. If the user has no IDs,
66         * no further checking is needed for this user.
67         */
68        $yubikeys = $this->users[$user]['yubi'];
69        if(count($yubikeys) === 0) return true;
70
71        /* Get the one-time password, the user has entered
72         * in the login form. From this OTP we have to extract the
73         * first 12 bytes. These bytes build the ID of the key, which
74         * is stored in the yubikey-mapping file.
75         */
76        $otp = $INPUT->str('otp');
77        $yid = substr($otp, 0, 12);
78        if(in_array($yid, $yubikeys) === false) return false;
79
80        /* A corresponding Yubikey ID was found, so we will check
81         * finally the entered OTP against the servers of Yubico.
82         */
83        $yubi = new Auth_Yubico($this->getConf('yubico_client_id'), $this->getConf('yubico_secret_key'));
84        $auth = $yubi->verify($otp);
85        return (PEAR::isError($auth) ? false : true);
86    }
87
88    /**
89     * Modify user data
90     *
91     * @author  Dirk Scheer <dirk@scheernet.de>
92     * @author  Chris Smith <chris@jalakai.co.uk>
93     * @param   string $user      nick of the user to be changed
94     * @param   array  $changes   array of field/value pairs to be changed (password will be clear text)
95     * @return  bool
96     */
97    public function modifyUser($user, $changes) {
98        global $ACT;
99        global $INPUT;
100        global $conf;
101        global $config_cascade;
102
103        // sanity checks, user must already exist and there must be something to change
104        if(($userinfo = $this->getUserData($user)) === false) return false;
105        if(!is_array($changes) || !count($changes)) return true;
106
107        // update userinfo with new data, remembering to encrypt any password
108        $newuser = $user;
109        foreach($changes as $field => $value) {
110            if($field == 'user') {
111                $newuser = $value;
112                continue;
113            }
114            if($field == 'pass') $value = auth_cryptPassword($value);
115            $userinfo[$field] = $value;
116        }
117
118        // Check all entered Yubikeys
119        $yubi   = new Auth_Yubico($this->getConf('yubico_client_id'), $this->getConf('yubico_secret_key'));
120        $errors = array();
121        $userinfo['yubi'] = array();
122        for($i=0; $i < intval($this->getConf('yubico_maxkeys')); $i++) {
123            $otp = $INPUT->str('yubikeyid'.$i);
124            if($otp !== '') {
125                if($otp == $this->users[$user]['yubi'][$i]) {
126                    array_push($userinfo['yubi'], substr($otp, 0, 12));
127                }
128                else {
129                    $auth = $yubi->verify($otp);
130                    if(PEAR::isError($auth) && $auth != 'REPLAYED_OTP') {
131                        if($this->getConf('yubico_maxkeys') == 1) {
132                            array_push($errors, sprintf($this->getLang('yubikeyiderr'), substr($otp, 0, 12), $auth));
133                        }
134                        else {
135                            array_push($errors, sprintf($this->getLang('yubikeyidserr'), $i+1, substr($otp, 0, 12), $auth));
136                        }
137                    }
138                    else {
139                        array_push($userinfo['yubi'], substr($otp, 0, 12));
140                    }
141                }
142            }
143        }
144        if(count($errors) > 0) {
145            foreach($errors as $error) {
146                $errtext .= $error . '<BR>';
147            }
148            msg($errtext, -1);
149            return false;
150        }
151
152        $userline = $this->_createUserLine($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $userinfo['grps']);
153
154        if(!$this->deleteUsers(array($user))) {
155            msg($this->getLang('yubikeymodifyerr1'), -1);
156            return false;
157        }
158
159        if(!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) {
160            msg($this->getLang('yubikeymodifyerr2'), -1);
161            // FIXME, user has been deleted but not recreated, should force a logout and redirect to login page
162            $ACT = 'register';
163            return false;
164        }
165
166        $yubiline = '';
167        foreach($userinfo['yubi'] as $yubi) {
168            $yubiline .= $newuser . ':' . $yubi . "\n";
169        }
170        if(!io_saveFile(DOKU_CONF . 'users.yubikeys.php', $yubiline, true)) {
171            msg($this->getLang('yubikeymodifyerr3'), -1);
172            return false;
173        }
174
175        $this->users[$newuser] = $userinfo;
176        return true;
177    }
178
179    /**
180     * Remove one or more users from the list of registered users
181     *
182     * @author  Dirk Scheer <dirk@scheernet.de>
183     * @author  Christopher Smith <chris@jalakai.co.uk>
184     * @param   array  $users   array of users to be deleted
185     * @return  int             the number of users deleted
186     */
187    public function deleteUsers($users) {
188        global $config_cascade;
189
190        if(!is_array($users) || empty($users)) return 0;
191
192        if($this->users === null) $this->_loadUserData();
193
194        $deleted = array();
195        foreach($users as $user) {
196            if(isset($this->users[$user])) $deleted[] = preg_quote($user, '/');
197        }
198
199        if(empty($deleted)) return 0;
200
201        $pattern = '/^('.join('|', $deleted).'):/';
202        io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true);
203        io_deleteFromFile(DOKU_CONF . 'users.yubikeys.php', $pattern, true);
204
205        // reload the user list and count the difference
206        $count = count($this->users);
207        $this->_loadUserData();
208        $count -= count($this->users);
209        return $count;
210    }
211
212    /**
213     * Load all user data
214     *
215     * loads the user file into a datastructure
216     *
217     * @author  Dirk Scheer <dirk@scheernet.de>
218     * @author  Andreas Gohr <andi@splitbrain.org>
219     */
220    protected function _loadUserData() {
221        global $config_cascade;
222
223        $this->users = array();
224
225        if(!@file_exists($config_cascade['plainauth.users']['default'])) return;
226
227        $lines = file($config_cascade['plainauth.users']['default']);
228        foreach($lines as $line) {
229            $line = preg_replace('/#.*$/', '', $line); //ignore comments
230            $line = trim($line);
231            if(empty($line)) continue;
232
233            /* NB: preg_split can be deprecated/replaced with str_getcsv once dokuwiki is min php 5.3 */
234            $row = $this->_splitUserData($line);
235            $row = str_replace('\\:', ':', $row);
236            $row = str_replace('\\\\', '\\', $row);
237
238            $groups = array_values(array_filter(explode(",", $row[4])));
239
240            $this->users[$row[0]]['pass'] = $row[1];
241            $this->users[$row[0]]['name'] = urldecode($row[2]);
242            $this->users[$row[0]]['mail'] = $row[3];
243            $this->users[$row[0]]['grps'] = $groups;
244            $this->users[$row[0]]['yubi'] = array();
245        }
246
247        /* Read the mapping table for Yubikeys */
248        $lines = file(DOKU_CONF . 'users.yubikeys.php');
249        foreach($lines as $line) {
250            $line = preg_replace('/#.*$/', '', $line); //ignore comments
251            $line = trim($line);
252            if(empty($line)) continue;
253
254            list($user, $yubikey) = explode(':', $line);
255            if(isset($this->users[$user])) {
256                array_push($this->users[$user]['yubi'], $yubikey);
257            }
258        }
259    }
260}
261