1<?php
2
3use dokuwiki\Extension\Event;
4use JetBrains\PhpStorm\NoReturn;
5
6/**
7 * Provides generic SSO authentication
8 * @author Etienne MELEARD <etienne.meleard@renater.fr>
9 * @date 2018-08-27
10 */
11
12class auth_plugin_genericsso extends DokuWiki_Auth_Plugin {
13    /** @var bool */
14    public $success = false;
15
16    /**
17     * Possible things an auth backend module may be able to
18     * do. The things a backend can do need to be set to true
19     * in the constructor.
20     *
21     * @var array
22     */
23    protected $cando = [
24        'addUser'      => false, // can Users be created?
25        'delUser'      => false, // can Users be deleted?
26        'modLogin'     => false, // can login names be changed?
27        'modPass'      => false, // can passwords be changed?
28        'modName'      => false, // can real names be changed?
29        'modMail'      => false, // can emails be changed?
30        'modGroups'    => false, // can groups be changed?
31        'getUsers'     => false, // can a (filtered) list of users be retrieved?
32        'getUserCount' => false, // can the number of users be retrieved?
33        'getGroups'    => false, // can a list of available groups be retrieved?
34        'external'     => true,  // does the module do external auth checking?
35        'logout'       => true,  // can the user logout again? (eg. not possible with HTTP auth)
36    ];
37
38    private array|null $_conf = null;
39    private array|null $_attrs = null;
40    private array|null $_users = null;
41
42    /**
43     * Constructor
44     */
45    public function __construct() {
46        parent::__construct();
47
48        $this->_getConf(); // Checks config
49
50        $this->success = true;
51    }
52
53    /**
54     * Get config if valid
55     *
56     * @throw Exception
57     */
58    private function _getConf(string $param = null): array|string|null {
59        if(!$this->_conf) {
60            global $conf;
61            $this->_conf = $conf['plugin']['genericsso'];
62
63            $bad = [];
64            foreach([
65                'login_url', 'logout_url', 'home_url', 'headers', 'autologin',
66                'idp_attribute', 'id_attribute', 'email_attribute', 'fullname_attribute'
67            ] as $p) {
68                if(!array_key_exists($p, $this->_conf))
69                    $bad[] = $p;
70            }
71
72            if($bad) {
73                msg('Bad configuration for Dokuwiki SSO login : '.implode(', ', $bad), -1);
74                return null;
75            }
76        }
77
78        if($param) {
79            if(!array_key_exists($param, $this->_conf)) {
80                msg('Unknown configuration parameter for Dokuwiki SSO login : '.$param, -1);
81                return null;
82            }
83
84            return $this->_conf[$param];
85        }
86
87        return $this->_conf;
88    }
89
90    /**
91     * Get attributes if any
92     *
93     * @throw Exception
94     */
95    private function _getAttributes(bool $fatal = true, string $attr = null): array|null {
96        if(is_null($this->_attrs)) {
97            $headers = $this->_getConf('headers') ? getallheaders() : null;
98
99            $this->_attrs = [];
100            foreach(['idp', 'id', 'email', 'fullname'] as $k) {
101                $src = $this->_getConf($k.'_attribute');
102                if($headers) {
103                    $this->_attrs[$k] = array_key_exists($src, $headers) ? $headers[$src] : null;
104                } else {
105                    $this->_attrs[$k] = getenv($src);
106                }
107            }
108        }
109
110        $bad = array_map(function($k) {
111            return $this->_getConf($k.'_attribute');
112        }, array_keys(array_filter($this->_attrs, 'is_null')));
113
114        if($bad && $fatal) {
115            msg('Missing attribute(s) for Dokuwiki SSO login : '.implode(', ', $bad), -1);
116            return [];
117        }
118
119        if($attr) {
120            $v = array_key_exists($attr, $this->_attrs) ? $this->_attrs[$attr] : null;
121
122            if($v)
123                return $v;
124
125            if($fatal)
126                msg('Missing attribute(s) for Dokuwiki SSO login : '.implode(', ', $bad), -1);
127
128            return null;
129        }
130
131        return $this->_attrs;
132    }
133
134    /**
135     * Check if any attributes
136     */
137    private function _hasAttributes(string $attr = null): bool {
138        $attrs = $this->_getAttributes(false);
139
140        if(is_string($attr))
141            return array_key_exists($attr, $attrs) && !is_null($attrs[$attr]);
142
143        return count(array_filter($this->_attrs)) > 0;
144    }
145
146    /**
147     * Log info
148     */
149    private function _log(string $msg): void {
150        error_log('Dokuwiki SSO plugin: '.$msg);
151    }
152
153    /**
154     * Go to URL
155     */
156    #[NoReturn] private function _goto(string $url, string $target = ''): void {
157        $url = $url ? str_replace('{target}', $target, $url) : $target;
158
159        $this->_log('redirecting user to '.$url);
160        header('Location: '.$url);
161        exit;
162    }
163
164
165    /**
166     * Check authentication
167     *
168     * @param string $user
169     * @param string $pass
170     * @param bool $sticky
171     */
172    public function trustExternal($user, $pass, $sticky = false) {
173        $do = array_key_exists('do', $_REQUEST) ? $_REQUEST['do'] : null;
174        $autologin = $this->_getConf('autologin');
175        $has_attributes = $this->_hasAttributes();
176        $has_session = $this->_hasSession();
177
178        $state = ['autologin' => $autologin, 'has_attributes' => $has_attributes, 'has_session' => $has_session];
179        $state = preg_replace('`(\n|\s+)`', ' ', print_r($state, true));
180
181        if($do === 'login' && !$has_attributes)
182            $this->_login();
183
184        if($do === 'logout' && $has_attributes)
185            $this->_logout();
186
187        if($do === 'login' || ($autologin && $has_attributes && !$has_session)) {
188            $attrs = $this->_getAttributes();
189            $data = $this->getUserData($user);
190            $this->_setSession($attrs['id'], $data['grps'], $attrs['email'], $attrs['fullname']);
191            $this->_log('authenticated user (state='.$state.')');
192            return;
193        }
194
195        if($do === 'logout' || ($autologin && !$has_attributes && $has_session)) {
196            $this->_dropSession();
197            $this->_log('logged user out (state='.$state.')');
198            return;
199        }
200
201        // Check user match is SSO and local session
202        if($autologin && $has_session && $has_attributes) {
203            if($_SESSION[DOKU_COOKIE]['auth']['user'] !== $this->_getAttributes(false, 'id')) {
204                $this->_log('SSO user doesn\'t match local user, logging out (state='.$state.')');
205                $this->_logout();
206            }
207        }
208
209        // Refresh from cookie if any
210        auth_login(null, null);
211    }
212
213    /**
214     * Check if local session exists
215     */
216    private function _hasSession(): bool {
217        return array_key_exists(DOKU_COOKIE, $_SESSION) && array_key_exists('auth', $_SESSION[DOKU_COOKIE]) && $_SESSION[DOKU_COOKIE]['auth']['user'];
218    }
219
220    /**
221     * Create user session
222     */
223    private function _setSession(string $user, array $grps = null, string $mail = null, string $name = null): void {
224        global $USERINFO;
225        global $INPUT;
226
227        $USERINFO['name'] = $name ?: $user;
228        $USERINFO['mail'] = $mail ?: (mail_isvalid($user) ? $user : null);
229        $USERINFO['grps'] = array_filter((array)$grps);
230
231        $INPUT->server->set('REMOTE_USER', $user);
232
233        $secret = auth_cookiesalt(true, true);
234        $pass = hash_hmac('sha1', $user, $secret);
235        auth_setCookie($user, auth_encrypt($pass, $secret), false);
236
237        $dummy = [];
238        trigger_event('AUTH_EXTERNAL', $dummy);
239    }
240
241    /**
242     * Remove session data
243     */
244    private function _dropSession(): void {
245        auth_logoff();
246
247        $dummy = [];
248        trigger_event('AUTH_EXTERNAL', $dummy);
249    }
250
251    /**
252     * Redirect for login
253     */
254    #[NoReturn] public function _login(): void {
255        $this->_dropSession();
256        $this->_goto($this->_getConf('login_url'), wl(getId()));
257    }
258
259    /**
260     * Redirect for logout
261     */
262    #[NoReturn] public function _logout(): void {
263        $this->_dropSession();
264        $this->_goto($this->_getConf('logout_url'), $this->_getConf('home_url'));
265    }
266
267    /**
268     * Check password (not used but required by inheritance)
269     *
270     * @param string $user
271     * @param string $pass
272     */
273    public function checkPass($user, $pass): bool {
274        if($_SESSION[DOKU_COOKIE]['auth']['user'] !== $user)
275            return false;
276
277        $attrs = $this->_getAttributes();
278        if($user !== $attrs['id'])
279            return false;
280
281        $secret = auth_cookiesalt(true, true);
282        if($pass !== hash_hmac('sha1', $user, $secret))
283            return false;
284
285        return true;
286    }
287
288    /**
289     * Get user info
290     *
291     * @param string $user
292     * @param bool $requireGroups
293     */
294    public function getUserData($user, $requireGroups = true): array {
295        if(is_null($this->_users)) {
296            $this->_users = [];
297            if(@file_exists(DOKU_CONF.'users.auth.php')) {
298                foreach(file(DOKU_CONF.'users.auth.php') as $line) {
299                    $line = trim(preg_replace('/#.*$/', '', $line)); //ignore comments
300                    if(!$line) continue;
301                    $row = explode(':', $line, 5);
302                    $this->_users[$row[0]] = [
303                        'pass' => $row[1],
304                        'name' => urldecode($row[2]),
305                        'mail' => $row[3],
306                        'grps' => explode(',', $row[4])
307                    ];
308                }
309            }
310        }
311
312        // Any user virtualy exists
313        $data = ['name' => $user, 'mail' => $user, 'grps' => []];
314
315        global $INPUT;
316        if($user === $INPUT->server->str('REMOTE_USER')) {
317            $attrs = $this->_getAttributes();
318            $data = ['name' => $attrs['fullname'], 'mail' => $attrs['email'], 'grps' => []];
319        }
320
321        $grps = array_key_exists($user, $this->_users) ? $this->_users[$user]['grps'] : [];
322        $data['grps'] = array_unique(array_merge($grps, $data['grps'], ['session']));
323
324        return $requireGroups ? $data : array_diff_key($data, ['grps' => null]);
325    }
326}
327