1<?php
2/**
3 * SAML authentication plugin
4 *
5 * @author     Andreas Gohr <gohr@cosmocode.de>
6 * @author     Sam Yelman <sam.yelman@temple.edu>
7 */
8class auth_plugin_saml extends auth_plugin_authplain
9{
10    /** @var OneLogin_Saml2_Auth the SAML authentication library */
11    protected $saml;
12
13    /** @inheritdoc */
14    public function __construct()
15    {
16        parent::__construct();
17
18        $this->cando['external'] = true;
19        $this->cando['logoff'] = true;
20        /* We only want auth_plain for e-mail tracking and group storage */
21        $this->cando['addUser'] = false;
22        $this->cando['modLogin'] = false;
23        $this->cando['modPass'] = false;
24        $this->cando['modName'] = false;
25        $this->cando['modMail'] = false;
26        $this->cando['modGroups'] = false;
27
28        /** @var helper_plugin_saml $hlp */
29        $hlp = plugin_load('helper', 'saml');
30        $this->saml = $hlp->getSamlLib();
31    }
32
33    /**
34     * Checks the session to see if the user is already logged in
35     *
36     * If not logged in, redirects to SAML provider
37     */
38    public function trustExternal($user, $pass, $sticky = false)
39    {
40        global $USERINFO;
41        global $ID;
42        global $ACT;
43
44        $autoLogin = false;
45
46        if (empty($ID)) $ID = getID();
47
48        // trust session info, no need to recheck
49        if (isset($_SESSION[DOKU_COOKIE]['auth']) &&
50            $_SESSION[DOKU_COOKIE]['auth']['buid'] == auth_browseruid() &&
51            isset($_SESSION[DOKU_COOKIE]['auth']['user'])
52        ) {
53
54            $_SERVER['REMOTE_USER'] = $_SESSION[DOKU_COOKIE]['auth']['user'];
55            $USERINFO = $_SESSION[DOKU_COOKIE]['auth']['info'];
56
57            return true;
58        } else {
59            $autoLoginConf = $this->getConf("auto_login");
60            $autoLogin = ($autoLoginConf == "never") ? false : (
61                ($autoLoginConf == "after login" && get_doku_pref('saml_autologin', 0)) ||
62                ($autoLoginConf == "always"));
63        }
64
65        if (!isset($_POST['SAMLResponse']) && ($ACT == 'login' || $autoLogin)) {
66            // Initiate SAML auth request
67            $url = $this->saml->login(
68                null, // returnTo: is configured in our settings
69                [], // parameter: we do not send any additional paramters to ndreas
70                false, // forceAuthn: would skip any available SSO data, not what we want
71                false, // isPassive: would avoid all user interaction, not what we want
72                true, // stay: do not redirect, we do that ourselves
73                false // setNamedIdPolicy: we need to disable this or SAML complains about our request
74            );
75            $_SESSION['saml_redirect'] = wl($ID, '', true, '&'); // remember current page
76            send_redirect($url);
77        } elseif (isset($_POST['SAMLResponse'])) {
78            // consume SAML response
79            try {
80                $this->saml->processResponse();
81                if ($this->saml->isAuthenticated()) {
82                    // Always read the userid from the saml response
83                    $USERINFO = $this->getUserDataFromResponse();
84                    $_SERVER['REMOTE_USER'] = $USERINFO['user'];
85
86                    if ($this->getConf('autoprovisioning')) {
87                        // In case of auto-provisionning we override the local DB info with those retrieve during the SAML negociation
88                        if ( $this->getUserData($USERINFO['user']) === false ) {
89                            $this->triggerUserMod('create', array(
90                                $USERINFO['user'],
91                                "\0\0nil\0\0",
92                                $USERINFO['name'],
93                                $USERINFO['mail'],
94                                $USERINFO['grps']
95                            ));
96                        } else {
97                            $this->triggerUserMod('modify', array(
98                                  $USERINFO['user'],
99                                  $USERINFO
100                            ));
101                        }
102                    } else {
103                        // In case the autoprovisionning is disabled we rely on the local DB for the info such as the group and the fullname.
104                        // It also means that the user should exists already in the DB
105                        $dbUserInfo = $this->getUserData($USERINFO['user']);
106                        if($dbUserInfo === false) throw new \Exception('This user is not in the local user database and may not login');
107                        $USERINFO['name'] = $dbUserInfo["name"];
108                        $USERINFO['mail'] = $dbUserInfo["mail"];
109                        $USERINFO['grps'] = $dbUserInfo["grps"];
110                    }
111
112                    // Store that in the cookie
113                    $_SESSION[DOKU_COOKIE]['auth']['user'] = $_SERVER['REMOTE_USER'];
114                    $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
115                    $_SESSION[DOKU_COOKIE]['auth']['buid'] = auth_browseruid(); // cache login
116
117                    // successful login
118                    if (isset($_SESSION['saml_redirect'])) {
119                        $go = $_SESSION['saml_redirect'];
120                        unset($_SESSION['saml_redirect']);
121                    } else {
122                        $go = wl($ID, '', true, '&');
123                    }
124                    set_doku_pref('saml_autologin', 1);
125                    send_redirect($go); // decouple the history from POST
126                    return true;
127                } else {
128                    $this->logOff();
129
130                    msg('SAML: '.hsc($this->saml->getLastErrorReason()), -1);
131                    return false;
132                }
133            } catch (Exception $e) {
134                $this->logOff();
135                msg('Invalid SAML response: ' . hsc($e->getMessage()), -1);
136                return false;
137            }
138        }
139        // no login happened
140        return false;
141    }
142
143
144    /** @inheritdoc */
145    public function logOff()
146    {
147		global $ID;
148        set_doku_pref('saml_autologin', 0);
149
150		$hlp = plugin_load('helper', 'saml');
151		$saml = $hlp->getSamlLib();
152
153		/* By default, try to return to user to the page they were just viewing */
154		$redirTo = wl($ID, '', true, '&');
155
156		/* Proccess an SLO request or response */
157		if(isset($_GET["SAMLResponse"]) || isset($_GET["SAMLRequest"])) {
158			$saml->processSLO();
159			$errors = $saml->getErrors();
160
161			if (!empty($errors)) {
162				  msg('SAML SLO: '. implode(', ', $errors) . '; ' . $saml->getLastErrorReason(), -1);
163			}
164
165			/* If a RelayState is defined in the Request, this is where we want to redirect to afterwards */
166			if(isset($_GET["RelayState"])) $redirTo = $_GET["RelayState"];
167
168		/* If user initiates logout from the wiki itself */
169		} else if($this->getConf('use_slo')) {
170			$saml->logout($redirTo);
171		}
172
173		/* Manually redirect user if we ever get here */
174		send_redirect($redirTo);
175    }
176
177    /** @inheritdoc */
178    public function cleanUser($user)
179    {
180        // strip disallowed characters
181        $user = strtr(
182            $user, array(
183                ',' => '',
184                '/' => '',
185                '#' => '',
186                ';' => '',
187                ':' => ''
188            )
189        );
190        if ($this->getConf('lowercase')) {
191            return utf8_strtolower($user);
192        } else {
193            return $user;
194        }
195    }
196
197    /** @inheritdoc */
198    public function cleanGroup($group)
199    {
200        return $this->cleanUser($group);
201    }
202
203
204    /**
205     * Build user data from the response
206     *
207     * @return array the user data
208     * @throws Exception when attributes are missing
209     */
210    protected function getUserDataFromResponse()
211    {
212        global $conf;
213
214        // which attributes should be in the response?
215        $attributes = [
216            'user' => $this->getConf('userid_attr_name')
217        ];
218        if ($this->getConf('autoprovisioning')) {
219            $attributes['name'] = $this->getConf('fullname_attr_name');
220            if (empty($attributes['name'])) $attributes['name'] = $attributes['user']; // fall back to login
221            $attributes['mail'] = $this->getConf('email_attr_name');
222            $attributes['grps'] = $this->getConf('groups_attr_name');
223            if (empty($attributes['grps'])) unset($attributes['grps']); // groups are optional
224        }
225
226        // get attributes from response
227        $userdata = ['user' => '', 'mail' => '', 'name' => '', 'grps' => []];
228        foreach ($attributes as $key => $attr) {
229            $data = $this->saml->getAttribute($attr);
230            if ($data === null) throw new \Exception('SAML Response is missing attribute ' . $attr);
231            $userdata[$key] = $data;
232        }
233
234        // clean up data
235        $userdata['user'] = $this->cleanUser($userdata['user'][0]);
236        $userdata['name'] = $userdata['name'][0];
237        $userdata['mail'] = $userdata['mail'][0];
238        $userdata['grps'] = (array)$userdata['grps'];
239        $userdata['grps'][] = $conf['defaultgroup'];
240        $userdata['grps'] = array_map([$this, 'cleanGroup'], $userdata['grps']);
241
242        return $userdata;
243    }
244}
245