aboutsummaryrefslogtreecommitdiff
path: root/lib/classes/TwoFactorAuth.php
blob: 70d05676b27b35e062211fd1b45c3551372ddfc9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
<?php
/**
 * Class handling the two factor authentication
 *
 * @author  Jan-Hendrik Willms <tleilax+studip@gmail.com>
 * @license GPL2 or any later version
 * @since   Stud.IP 4.4
 *
 * @see TFASecret model
 */
final class TwoFactorAuth
{
    const SESSION_KEY           = 'tfa/confirmed';
    const SESSION_REDIRECT      = 'tfa/redirect';
    const SESSION_ENFORCE       = 'tfa/enforce';
    const SESSION_DATA          = 'tfa/data';
    const SESSION_CONFIRMATIONS = 'tfa/confirmations';
    const SESSION_FAILED        = 'tfa/failed';

    const COOKIE_KEY = 'tfa/authentication';

    private static $instance = null;

    /**
     * Returns an instance of the authentication
     * @return TwoFactorAuth object
     */
    public static function get()
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Returns whether the two factor authentication is enabled for the given
     * user (defaults to current user). The user's permissions decide whether
     * the two factor authentication is enabled or not.
     *
     * @param  User  $user User to check (optional, defaults to current user)
     * @return boolean
     */
    public static function isEnabledForUser(User $user = null)
    {
        if ($user === null) {
            $user = User::findCurrent();
        }

        $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->TFA_PERMS)));
        return in_array($user->perms, $valid_perms);
    }

    public static function removeCookie()
    {
        // Remove cookie
        setcookie(
            self::COOKIE_KEY . '/' . $GLOBALS['user']->id,
            '',
            strtotime('-1 year'),
            $GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']
        );
    }

    private $secret = null;

    /**
     * Private constructor to enforce singleton
     */
    private function __construct()
    {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            throw new Exception('2FA requires a valid session');
        }

        if (!isset($_SESSION[self::SESSION_FAILED])) {
            $_SESSION[self::SESSION_FAILED] = [];
        } else {
            // Remove failed items after 5 minutes
            $_SESSION[self::SESSION_FAILED] = array_filter(
                $_SESSION[self::SESSION_FAILED],
                function ($timestamp) {
                    return $timestamp > time() - Config::get()->TFA_MAX_TRIES_TIMESPAN;
                }
            );
        }

        $user = User::findCurrent();
        if (!$user) {
            return;
        }

        if (!self::isEnabledForUser($user)) {
            return;
        }

        if (!TFASecret::exists($user->id)) {
            return;
        }

        $this->secret = TFASecret::find($user->id);
    }

    /**
     * Secures the current session, if applicable.
     *
     * This method checks the following:
     * - is 2fa enabled for the current user
     * - is the request an ajax call
     * - does the user have a secret, meaning 2fa is enabled
     * - is the secret already confirmed
     * - has the session already been confirmed (identified by a valid random
     *   token stored in the session)
     * - is the computer trusted (identified by a valid random token stored in
     *   a cookie)
     *
     * If the user has 2fa enabled, it's secret is confirmed and the session has
     * not been secured yet, a validation screen with a prompt to enter a valid
     * token is presented to the user.
     */
    public function secureSession()
    {
        // Not enabled for user's perm?
        if (!self::isEnabledForUser()) {
            return;
        }

        // TODO: AJAX?
        if (Request::isXhr()) {
            return;
        }

        // Not enabled?
        if (!$this->secret) {
            return;
        }

        $this->validateFromRequest();

        // Not confirmed?
        if (!$this->secret->confirmed) {
            return;
        }

        // User has already confirmed this session?
        if (isset($_SESSION[self::SESSION_KEY])) {
            list($code, $timeslice) = array_values($_SESSION[self::SESSION_KEY]);
            if ($this->secret->validateToken($code, (int) $timeslice, true)) {
                return;
            }
            unset($_SESSION[self::SESSION_KEY]);
        }

        // Trusted computer?
        $user_cookie_key = self::COOKIE_KEY . '/' . $GLOBALS['user']->id;
        if (isset($_COOKIE[$user_cookie_key])) {
            list($code, $timeslice) = explode(':', $_COOKIE[$user_cookie_key]);
            if ($this->secret->validateToken($code, (int) $timeslice, true)) {
                $this->registerSecretInSession();
                return;
            }

            self::removeCookie();
        }

        $this->showConfirmationScreen('', [
            'global' => true,
        ]);
    }

    /**
     * Requests a 2fa token input to confirm a specific action.
     *
     * @param  string $action Name of the action to confirm
     * @param  string $text   Text to display to the user
     * @param  array  $data   Optional additional data to pass to the
     *                        confirmation screen (for internal use)
     */
    public function confirm($action, $text, array $data = []): void
    {
        if (isset($_SESSION[self::SESSION_CONFIRMATIONS])
            && is_array($_SESSION[self::SESSION_CONFIRMATIONS])
            && in_array($action, $_SESSION[self::SESSION_CONFIRMATIONS]))
        {
            $_SESSION[self::SESSION_CONFIRMATIONS] = array_diff(
                $_SESSION[self::SESSION_CONFIRMATIONS],
                [$action]
            );
        } else {
            $this->showConfirmationScreen($text, $data + [
                'confirm' => $action,
            ]);
        }
    }

    /**
     * Displays the token input screen to the user. This will be the last
     * action since it dies after display.
     *
     * @param  string $text Text to display to the user
     * @param  array  $data Optional additional data (for internal use)
     */
    private function showConfirmationScreen($text = '', array $data = [])
    {
        $data = array_merge(['global' => false], $data);

        $_SESSION[self::SESSION_DATA] = array_merge($data, [
            '__nonce'  => md5(uniqid('tfa-nonce', true)),
            '__params' => Request::getInstance()->getIterator()->getArrayCopy(),
        ]);

        if ($this->secret->type === 'email') {
            StudipMail::sendMessage(
                $this->secret->user->email,
                _('Ihr Zwei-Faktor-Token'),
                sprintf(
                    _('Bitte geben Sie dieses Token ein: %s'),
                    $this->secret->getToken()
                )
            );
        }
        PageLayout::setBodyElementId('tfa-confirmation-screen');

        echo $GLOBALS['template_factory']->render(
            'tfa-validate.php',
            $_SESSION[self::SESSION_DATA] + [
                'secret'   => $this->secret,
                'text'     => $text,
                'blocked'  => $this->isBlocked(),
                'duration' => Config::get()->TFA_TRUST_DURATION,
            ],
            'layouts/base.php'
        );
        page_close();
        die;
    }

    /**
     * Registers the current secret in session by storing a valid random token
     * along with the according timeslice.
     */
    private function registerSecretInSession()
    {
        $timeslice = mt_rand(0, PHP_INT_MAX);
        $_SESSION[self::SESSION_KEY] = [
            'code'      => $this->secret->getToken($timeslice),
            'timeslice' => $timeslice,
        ];
    }

    /**
     * Registers the current secret in a cookie by storing a valid random token
     * along with the according timeslice.
     */
    private function registerSecretInCookie()
    {
        $lifetime_in_days = Config::get()->TFA_TRUST_DURATION;
        $lifetime = $lifetime_in_days > 0 ? strtotime("+{$lifetime_in_days} days") : 2147483647;

        $timeslice = mt_rand(0, PHP_INT_MAX);
        setcookie(
            self::COOKIE_KEY . '/' . $GLOBALS['user']->id,
            implode(':', [$this->secret->getToken($timeslice), $timeslice]),
            $lifetime,
            $GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']
        );
    }

    /**
     * Detects and validates a submitted tfa token input. This will stop the
     * current request if token is present and invalid and will return to the
     * request as expected when either token is present and valid or no token
     * was submitted at all.
     *
     * This method also registers the secret in session (if global in data is
     * set to true) or registers the secret in a cookie (if request parameter
     * "tfa-trusted" was sent).
     */
    private function validateFromRequest()
    {
        if (
            $this->isBlocked()
            || !Request::isPost()
            || !Request::submitted('tfacode-input')
            || !Request::submitted('tfa-nonce')
            || !isset($_SESSION[self::SESSION_DATA])
            || !is_array($_SESSION[self::SESSION_DATA])
        ) {
            return;
        }

        $data = $_SESSION[self::SESSION_DATA];
        if (Request::option('tfa-nonce') === $data['__nonce']) {
            $token = implode('', Request::intArray('tfacode-input'));

            if ($this->secret->validateToken($token)) {
                $_SESSION[self::SESSION_FAILED] = [];

                if ($data['global'] ?: false) {
                    $this->registerSecretInSession();

                    if (Request::int('tfa-trusted')) {
                        $this->registerSecretInCookie();
                    }
                }

                if ($data['confirm'] ?: false) {
                    if (!isset($_SESSION[self::SESSION_CONFIRMATIONS])) {
                        $_SESSION[self::SESSION_CONFIRMATIONS] = [];
                    }
                    $_SESSION[self::SESSION_CONFIRMATIONS][] = $data['confirm'];
                }

                // Remove tfa parameters from request
                Request::set('tfa-nonce', null);
                Request::set('tfacode-input', null);
                Request::set('tfa-trusted', null);

                // Add previous parameters to request
                foreach ($data['__params'] as $key => $value) {
                    Request::set($key, $value);
                }
            } else {
                $_SESSION[self::SESSION_FAILED][] = time();

                PageLayout::postError(_('Das eingegebene Token ist nicht gültig.'));
            }

            unset($_SESSION[self::SESSION_DATA]);
        }
    }

    /**
     * Returns whether the current session is blocked from any more token
     * inputs. This happens if too many false inputs happen in a short amount
     * of time and should prevent brute force attacks.
     *
     * @return boolean
     */
    private function isBlocked()
    {
        return count($_SESSION[self::SESSION_FAILED]) >= Config::get()->TFA_MAX_TRIES
             ? min($_SESSION[self::SESSION_FAILED])
             : false;
    }
}