1<?php
2
3
4/**
5 * This is a helper class for saving and loading state information.
6 *
7 * The state must be an associative array. This class will add additional keys to this
8 * array. These keys will always start with 'SimpleSAML_Auth_State.'.
9 *
10 * It is also possible to add a restart URL to the state. If state information is lost, for
11 * example because it timed out, or the user loaded a bookmarked page, the loadState function
12 * will redirect to this URL. To use this, set $state[SimpleSAML_Auth_State::RESTART] to this
13 * URL.
14 *
15 * Both the saveState and the loadState function takes in a $stage parameter. This parameter is
16 * a security feature, and is used to prevent the user from taking a state saved one place and
17 * using it as input a different place.
18 *
19 * The $stage parameter must be a unique string. To maintain uniqueness, it must be on the form
20 * "<classname>.<identifier>" or "<module>:<identifier>".
21 *
22 * There is also support for passing exceptions through the state.
23 * By defining an exception handler when creating the state array, users of the state
24 * array can call throwException with the state and the exception. This exception will
25 * be passed to the handler defined by the EXCEPTION_HANDLER_URL or EXCEPTION_HANDLER_FUNC
26 * elements of the state array.
27 *
28 * @author Olav Morken, UNINETT AS.
29 * @package SimpleSAMLphp
30 */
31class SimpleSAML_Auth_State
32{
33
34
35    /**
36     * The index in the state array which contains the identifier.
37     */
38    const ID = 'SimpleSAML_Auth_State.id';
39
40
41    /**
42     * The index in the cloned state array which contains the identifier of the
43     * original state.
44     */
45    const CLONE_ORIGINAL_ID = 'SimpleSAML_Auth_State.cloneOriginalId';
46
47
48    /**
49     * The index in the state array which contains the current stage.
50     */
51    const STAGE = 'SimpleSAML_Auth_State.stage';
52
53
54    /**
55     * The index in the state array which contains the restart URL.
56     */
57    const RESTART = 'SimpleSAML_Auth_State.restartURL';
58
59
60    /**
61     * The index in the state array which contains the exception handler URL.
62     */
63    const EXCEPTION_HANDLER_URL = 'SimpleSAML_Auth_State.exceptionURL';
64
65
66    /**
67     * The index in the state array which contains the exception handler function.
68     */
69    const EXCEPTION_HANDLER_FUNC = 'SimpleSAML_Auth_State.exceptionFunc';
70
71
72    /**
73     * The index in the state array which contains the exception data.
74     */
75    const EXCEPTION_DATA = 'SimpleSAML_Auth_State.exceptionData';
76
77
78    /**
79     * The stage of a state with an exception.
80     */
81    const EXCEPTION_STAGE = 'SimpleSAML_Auth_State.exceptionStage';
82
83
84    /**
85     * The URL parameter which contains the exception state id.
86     */
87    const EXCEPTION_PARAM = 'SimpleSAML_Auth_State_exceptionId';
88
89
90    /**
91     * State timeout.
92     */
93    private static $stateTimeout = null;
94
95
96    /**
97     * Get the persistent authentication state from the state array.
98     *
99     * @param array $state The state array to analyze.
100     *
101     * @return array The persistent authentication state.
102     */
103    public static function getPersistentAuthData(array $state)
104    {
105        // save persistent authentication data
106        $persistent = array();
107
108        if (array_key_exists('PersistentAuthData', $state)) {
109            foreach ($state['PersistentAuthData'] as $key) {
110                if (isset($state[$key])) {
111                    $persistent[$key] = $state[$key];
112                }
113            }
114        }
115
116        // add those that should always be included
117        $mandatory = array(
118            'Attributes',
119            'Expire',
120            'LogoutState',
121            'AuthInstant',
122            'RememberMe',
123            'saml:sp:NameID'
124        );
125        foreach ($mandatory as $key) {
126            if (isset($state[$key])) {
127                $persistent[$key] = $state[$key];
128            }
129        }
130
131        return $persistent;
132    }
133
134
135    /**
136     * Retrieve the ID of a state array.
137     *
138     * Note that this function will not save the state.
139     *
140     * @param array &$state The state array.
141     * @param bool  $rawId Return a raw ID, without a restart URL. Defaults to FALSE.
142     *
143     * @return string  Identifier which can be used to retrieve the state later.
144     */
145    public static function getStateId(&$state, $rawId = false)
146    {
147        assert(is_array($state));
148        assert(is_bool($rawId));
149
150        if (!array_key_exists(self::ID, $state)) {
151            $state[self::ID] = SimpleSAML\Utils\Random::generateID();
152        }
153
154        $id = $state[self::ID];
155
156        if ($rawId || !array_key_exists(self::RESTART, $state)) {
157            // Either raw ID or no restart URL. In any case, return the raw ID.
158            return $id;
159        }
160
161        // We have a restart URL. Return the ID with that URL.
162        return $id.':'.$state[self::RESTART];
163    }
164
165
166    /**
167     * Retrieve state timeout.
168     *
169     * @return integer  State timeout.
170     */
171    private static function getStateTimeout()
172    {
173        if (self::$stateTimeout === null) {
174            $globalConfig = SimpleSAML_Configuration::getInstance();
175            self::$stateTimeout = $globalConfig->getInteger('session.state.timeout', 60 * 60);
176        }
177
178        return self::$stateTimeout;
179    }
180
181
182    /**
183     * Save the state.
184     *
185     * This function saves the state, and returns an id which can be used to
186     * retrieve it later. It will also update the $state array with the identifier.
187     *
188     * @param array  &$state The login request state.
189     * @param string $stage The current stage in the login process.
190     * @param bool   $rawId Return a raw ID, without a restart URL.
191     *
192     * @return string  Identifier which can be used to retrieve the state later.
193     */
194    public static function saveState(&$state, $stage, $rawId = false)
195    {
196        assert(is_array($state));
197        assert(is_string($stage));
198        assert(is_bool($rawId));
199
200        $return = self::getStateId($state, $rawId);
201        $id = $state[self::ID];
202
203        // Save stage
204        $state[self::STAGE] = $stage;
205
206        // Save state
207        $serializedState = serialize($state);
208        $session = SimpleSAML_Session::getSessionFromRequest();
209        $session->setData('SimpleSAML_Auth_State', $id, $serializedState, self::getStateTimeout());
210
211        SimpleSAML\Logger::debug('Saved state: '.var_export($return, true));
212
213        return $return;
214    }
215
216
217    /**
218     * Clone the state.
219     *
220     * This function clones and returns the new cloned state.
221     *
222     * @param array $state The original request state.
223     *
224     * @return array  Cloned state data.
225     */
226    public static function cloneState(array $state)
227    {
228        $clonedState = $state;
229
230        if (array_key_exists(self::ID, $state)) {
231            $clonedState[self::CLONE_ORIGINAL_ID] = $state[self::ID];
232            unset($clonedState[self::ID]);
233
234            SimpleSAML\Logger::debug('Cloned state: '.var_export($state[self::ID], true));
235        } else {
236            SimpleSAML\Logger::debug('Cloned state with undefined id.');
237        }
238
239        return $clonedState;
240    }
241
242
243    /**
244     * Retrieve saved state.
245     *
246     * This function retrieves saved state information. If the state information has been lost,
247     * it will attempt to restart the request by calling the restart URL which is embedded in the
248     * state information. If there is no restart information available, an exception will be thrown.
249     *
250     * @param string $id State identifier (with embedded restart information).
251     * @param string $stage The stage the state should have been saved in.
252     * @param bool   $allowMissing Whether to allow the state to be missing.
253     *
254     * @throws SimpleSAML_Error_NoState If we couldn't find the state and there's no URL defined to redirect to.
255     * @throws Exception If the stage of the state is invalid and there's no URL defined to redirect to.
256     *
257     * @return array|NULL  State information, or null if the state is missing and $allowMissing is true.
258     */
259    public static function loadState($id, $stage, $allowMissing = false)
260    {
261        assert(is_string($id));
262        assert(is_string($stage));
263        assert(is_bool($allowMissing));
264        SimpleSAML\Logger::debug('Loading state: '.var_export($id, true));
265
266        $sid = self::parseStateID($id);
267
268        $session = SimpleSAML_Session::getSessionFromRequest();
269        $state = $session->getData('SimpleSAML_Auth_State', $sid['id']);
270
271        if ($state === null) {
272            // Could not find saved data
273            if ($allowMissing) {
274                return null;
275            }
276
277            if ($sid['url'] === null) {
278                throw new SimpleSAML_Error_NoState();
279            }
280
281            \SimpleSAML\Utils\HTTP::redirectUntrustedURL($sid['url']);
282        }
283
284        $state = unserialize($state);
285        assert(is_array($state));
286        assert(array_key_exists(self::ID, $state));
287        assert(array_key_exists(self::STAGE, $state));
288
289        // Verify stage
290        if ($state[self::STAGE] !== $stage) {
291            /* This could be a user trying to bypass security, but most likely it is just
292             * someone using the back-button in the browser. We try to restart the
293             * request if that is possible. If not, show an error.
294             */
295
296            $msg = 'Wrong stage in state. Was \''.$state[self::STAGE].
297                '\', should be \''.$stage.'\'.';
298
299            SimpleSAML\Logger::warning($msg);
300
301            if ($sid['url'] === null) {
302                throw new Exception($msg);
303            }
304
305            \SimpleSAML\Utils\HTTP::redirectUntrustedURL($sid['url']);
306        }
307
308        return $state;
309    }
310
311
312    /**
313     * Delete state.
314     *
315     * This function deletes the given state to prevent the user from reusing it later.
316     *
317     * @param array &$state The state which should be deleted.
318     */
319    public static function deleteState(&$state)
320    {
321        assert(is_array($state));
322
323        if (!array_key_exists(self::ID, $state)) {
324            // This state hasn't been saved
325            return;
326        }
327
328        SimpleSAML\Logger::debug('Deleting state: '.var_export($state[self::ID], true));
329
330        $session = SimpleSAML_Session::getSessionFromRequest();
331        $session->deleteData('SimpleSAML_Auth_State', $state[self::ID]);
332    }
333
334
335    /**
336     * Throw exception to the state exception handler.
337     *
338     * @param array                      $state The state array.
339     * @param SimpleSAML_Error_Exception $exception The exception.
340     *
341     * @throws SimpleSAML_Error_Exception If there is no exception handler defined, it will just throw the $exception.
342     */
343    public static function throwException($state, SimpleSAML_Error_Exception $exception)
344    {
345        assert(is_array($state));
346
347        if (array_key_exists(self::EXCEPTION_HANDLER_URL, $state)) {
348            // Save the exception
349            $state[self::EXCEPTION_DATA] = $exception;
350            $id = self::saveState($state, self::EXCEPTION_STAGE);
351
352            // Redirect to the exception handler
353            \SimpleSAML\Utils\HTTP::redirectTrustedURL(
354                $state[self::EXCEPTION_HANDLER_URL],
355                array(self::EXCEPTION_PARAM => $id)
356            );
357        } elseif (array_key_exists(self::EXCEPTION_HANDLER_FUNC, $state)) {
358            // Call the exception handler
359            $func = $state[self::EXCEPTION_HANDLER_FUNC];
360            assert(is_callable($func));
361
362            call_user_func($func, $exception, $state);
363            assert(false);
364        } else {
365            /*
366             * No exception handler is defined for the current state.
367             */
368            throw $exception;
369        }
370    }
371
372
373    /**
374     * Retrieve an exception state.
375     *
376     * @param string|NULL $id The exception id. Can be NULL, in which case it will be retrieved from the request.
377     *
378     * @return array|NULL  The state array with the exception, or NULL if no exception was thrown.
379     */
380    public static function loadExceptionState($id = null)
381    {
382        assert(is_string($id) || $id === null);
383
384        if ($id === null) {
385            if (!array_key_exists(self::EXCEPTION_PARAM, $_REQUEST)) {
386                // No exception
387                return null;
388            }
389            $id = $_REQUEST[self::EXCEPTION_PARAM];
390        }
391
392        $state = self::loadState($id, self::EXCEPTION_STAGE);
393        assert(array_key_exists(self::EXCEPTION_DATA, $state));
394
395        return $state;
396    }
397
398
399    /**
400     * Get the ID and (optionally) a URL embedded in a StateID, in the form 'id:url'.
401     *
402     * @param string $stateId The state ID to use.
403     *
404     * @return array A hashed array with the ID and the URL (if any), in the 'id' and 'url' keys, respectively. If
405     * there's no URL in the input parameter, NULL will be returned as the value for the 'url' key.
406     *
407     * @author Andreas Solberg, UNINETT AS <andreas.solberg@uninett.no>
408     * @author Jaime Perez, UNINETT AS <jaime.perez@uninett.no>
409     */
410    public static function parseStateID($stateId)
411    {
412        $tmp = explode(':', $stateId, 2);
413        $id = $tmp[0];
414        $url = null;
415        if (count($tmp) === 2) {
416            $url = $tmp[1];
417        }
418        return array('id' => $id, 'url' => $url);
419    }
420}
421