From 51a0e793f0ca08a6e0ea0ad6b7181e662946eeef Mon Sep 17 00:00:00 2001 From: Jan-Hendrik Willms <tleilax+studip@gmail.com> Date: Wed, 13 Nov 2024 14:56:58 +0000 Subject: [PATCH] fix 2fa token waiting (and more cleaning up than should be, sorry), fixes #4855 Closes #4855 Merge request studip/studip!3640 --- lib/classes/Request.php | 2 ++ lib/classes/TwoFactorAuth.php | 52 +++++++++++++++++++---------------- templates/tfa-validate.php | 16 +++++++++-- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/lib/classes/Request.php b/lib/classes/Request.php index 1bb10798d82..ae294ae87d2 100644 --- a/lib/classes/Request.php +++ b/lib/classes/Request.php @@ -77,6 +77,8 @@ class Request implements ArrayAccess, IteratorAggregate /** * IteratorAggregate: Create iterator for request parameters. + * + * @return ArrayIterator */ public function getIterator(): Traversable { diff --git a/lib/classes/TwoFactorAuth.php b/lib/classes/TwoFactorAuth.php index 6c04b8e1ad8..b67f4befa26 100644 --- a/lib/classes/TwoFactorAuth.php +++ b/lib/classes/TwoFactorAuth.php @@ -11,8 +11,6 @@ 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'; @@ -26,7 +24,7 @@ final class TwoFactorAuth * Returns an instance of the authentication * @return TwoFactorAuth object */ - public static function get() + public static function get(): TwoFactorAuth { if (self::$instance === null) { self::$instance = new self(); @@ -39,16 +37,16 @@ final class TwoFactorAuth * 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) + * @param User|null $user User to check (optional, defaults to current user) * @return boolean */ - public static function isEnabledForUser(User $user = null) + public static function isEnabledForUser(User $user = null): bool { if ($user === null) { $user = User::findCurrent(); } - $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->TFA_PERMS))); + $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->getValue('TFA_PERMS')))); return in_array($user->perms, $valid_perms); } @@ -67,11 +65,13 @@ final class TwoFactorAuth /** * Private constructor to enforce singleton + * + * @throws SessionRequiredException */ private function __construct() { if (session_status() !== PHP_SESSION_ACTIVE) { - throw new Exception('2FA requires a valid session'); + throw new SessionRequiredException('2FA requires a valid session'); } if (!isset($_SESSION[self::SESSION_FAILED])) { @@ -81,7 +81,7 @@ final class TwoFactorAuth $_SESSION[self::SESSION_FAILED] = array_filter( $_SESSION[self::SESSION_FAILED], function ($timestamp) { - return $timestamp > time() - Config::get()->TFA_MAX_TRIES_TIMESPAN; + return $timestamp > time() - Config::get()->getValue('TFA_MAX_TRIES_TIMESPAN'); } ); } @@ -145,7 +145,7 @@ final class TwoFactorAuth // User has already confirmed this session? if (isset($_SESSION[self::SESSION_KEY])) { - list($code, $timeslice) = array_values($_SESSION[self::SESSION_KEY]); + [$code, $timeslice] = array_values($_SESSION[self::SESSION_KEY]); if ($this->secret->validateToken($code, (int) $timeslice, true)) { return; } @@ -155,7 +155,7 @@ final class TwoFactorAuth // 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]); + [$code, $timeslice] = explode(':', $_COOKIE[$user_cookie_key]); if ($this->secret->validateToken($code, (int) $timeslice, true)) { $this->registerSecretInSession(); return; @@ -177,7 +177,7 @@ final class TwoFactorAuth * @param array $data Optional additional data to pass to the * confirmation screen (for internal use) */ - public function confirm($action, $text, array $data = []): void + public function confirm(string $action, string $text, array $data = []): void { if ( isset($_SESSION[self::SESSION_CONFIRMATIONS]) @@ -202,7 +202,7 @@ final class TwoFactorAuth * @param string $text Text to display to the user * @param array $data Optional additional data (for internal use) */ - private function showConfirmationScreen($text = '', array $data = []) + private function showConfirmationScreen(string $text = '', array $data = []) { $data = array_merge(['global' => false], $data); @@ -235,8 +235,8 @@ final class TwoFactorAuth $_SESSION[self::SESSION_DATA] + [ 'secret' => $this->secret, 'text' => $text, - 'blocked' => $this->isBlocked(), - 'duration' => Config::get()->TFA_TRUST_DURATION, + 'waittime' => $this->getWaitingTime(), + 'duration' => Config::get()->getValue('TFA_TRUST_DURATION'), ], 'layouts/base.php' ); @@ -263,7 +263,7 @@ final class TwoFactorAuth */ private function registerSecretInCookie() { - $lifetime_in_days = Config::get()->TFA_TRUST_DURATION; + $lifetime_in_days = Config::get()->getValue('TFA_TRUST_DURATION'); $lifetime = $lifetime_in_days > 0 ? strtotime("+{$lifetime_in_days} days") : 2147483647; $timeslice = mt_rand(0, PHP_INT_MAX); @@ -288,7 +288,7 @@ final class TwoFactorAuth private function validateFromRequest() { if ( - $this->isBlocked() + $this->getWaitingTime() || !Request::isPost() || !Request::submitted('tfacode-input') || !Request::submitted('tfa-nonce') @@ -341,16 +341,20 @@ final class TwoFactorAuth } /** - * 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. + * Returns the time the user has to wait before entering a token. * - * @return boolean + * This happens if too many invalid tokens have been input. */ - private function isBlocked() + private function getWaitingTime(): int { - return count($_SESSION[self::SESSION_FAILED]) >= Config::get()->TFA_MAX_TRIES - ? min($_SESSION[self::SESSION_FAILED]) - : false; + if (count($_SESSION[self::SESSION_FAILED]) < Config::get()->getValue('TFA_MAX_TRIES')) { + return 0; + } + + $min_timestamp = min(array_slice($_SESSION[self::SESSION_FAILED], -Config::get()->getValue('TFA_MAX_TRIES'))); + + $wait_time = $min_timestamp + Config::get()->getValue('TFA_MAX_TRIES_TIMESPAN') - time(); + + return ceil($wait_time / 60); } } diff --git a/templates/tfa-validate.php b/templates/tfa-validate.php index f5221bc705f..f3a682155cd 100644 --- a/templates/tfa-validate.php +++ b/templates/tfa-validate.php @@ -1,17 +1,27 @@ +<?php +/** + * @var string $__nonce + * @var int $waittime + * @var string $text + * @var TFASecret $secret + * @var bool|int $global + * @var int $duration + */ +?> <form action="<?= htmlReady(Request::url()) ?>" method="post" class="default"> <input type="hidden" name="tfa-nonce" value="<?= htmlReady($__nonce) ?>"> <fieldset> <legend><?= _('Zwei-Faktor-Authentifizierung') ?></legend> -<? if ($blocked): ?> +<? if ($waittime): ?> <?= MessageBox::warning(_('Sie haben zu viele ungültige Versuche'), [sprintf( _('Versuchen Sie es in %u Minute(n) erneut'), - ceil((time() - $blocked) / 60) + $waittime )])->hideClose() ?> <? else: ?> <p><?= htmlReady($text ?: _('Bitte geben Sie ein gültiges Token ein')) ?></p> <? if ($secret->type === 'app' && !$secret->confirmed): ?> - <?= formatReady(Config::get()->TFA_TEXT_APP) ?> + <?= formatReady(Config::get()->getValue('TFA_TEXT_APP')) ?> <p> <?= _('Scannen Sie diesen Code mit Ihrer App ein und geben Sie ' . 'anschliessend ein gültiges Token ein.') ?> -- GitLab