CVE-2021-41171 (PHP, published 2021-10-22T19:15Z)
Description: value: eLabFTW is an open source electronic lab notebook manager for research teams. In versions of eLabFTW before 4.1.0, it allows attackers to bypass a brute-force protection mechanism by using many different forged PHPSESSID values in HTTP Cookie header. This issue has been addressed by implementing brute force login protection, as recommended by Owasp with Device Cookies. This mechanism will not impact users and will effectively thwart any brute-force attempts at guessing passwords. The only correct way to address this is to upgrade to version 4.1.0. Adding rate limitation upstream of the eLabFTW service is of course a valid option, with or without upgrading.
Severity: MEDIUM (CVSS v3 base score: 8.8)
Weakness: CWE-307 \u2014 Improper Restriction of Excessive Authentication Attempts
The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame, making it more susceptible to brute force attacks.
Repository: https://github.com/elabftw/elabftw
Files changed:
composer.json
composer.lock
src/classes/App.php
src/classes/Auth.php
src/classes/Csrf.php
src/classes/Db.php
src/classes/Update.php
src/commands/CheckDatabase.php
src/commands/UpdateDatabase.php
src/controllers/LoginController.php
src/exceptions/DatabaseErrorException.php
src/exceptions/FilesystemErrorException.php
src/exceptions/IllegalActionException.php
src/exceptions/ImproperActionException.php
src/exceptions/InvalidCredentialsException.php
src/exceptions/InvalidDeviceTokenException.php
src/exceptions/InvalidSchemaException.php
src/exceptions/ProcessFailedException.php
src/exceptions/QuantumException.php
src/exceptions/ResourceNotFoundException.php
src/exceptions/UnauthorizedException.php
src/exceptions/WithMessageException.php
src/models/AnonymousUser.php
src/models/AuthFail.php
src/models/AuthenticatedUser.php
src/models/Config.php
src/models/ExistingUser.php
src/models/Tags.php
src/models/Uploads.php
src/models/Users.php
src/models/ValidatedUser.php
src/services/DeviceToken.php
src/services/DeviceTokenValidator.php
src/services/ExternalAuth.php
src/services/ImportCsv.php
src/services/LdapAuth.php
src/services/LocalAuth.php
src/services/LoginHelper.php
src/services/MfaAuth.php
src/services/Populate.php
src/services/SamlAuth.php
src/sql/schema63.sql
src/sql/structure.sql
src/templates/admin.html
src/templates/head.html
src/templates/login.html
src/templates/sysconfig.html
src/tools/phpstan.neon
src/ts/sysconfig.ts
tests/docker-compose.yml
tests/unit/classes/AuthTest.php
tests/unit/models/AuthFailTest.php
tests/unit/models/UsersTest.php
tests/unit/services/DeviceTokenTest.php
tests/unit/services/DeviceTokenValidatorTest.php
tests/unit/services/LocalAuthTest.php
web/admin.php
web/app/controllers/AdminController.php
web/app/controllers/ApiController.php
web/app/controllers/CreateUserController.php
web/app/controllers/EntityAjaxController.php
web/app/controllers/IdpsController.php
web/app/controllers/ImportController.php
web/app/controllers/LoginController.php
web/app/controllers/RegisterController.php
web/app/controllers/RequestHandler.php
web/app/controllers/ResetPasswordController.php
web/app/controllers/SortableAjaxController.php
web/app/controllers/SysconfigAjaxController.php
web/app/controllers/TeamController.php
web/app/controllers/UcpController.php
web/app/controllers/UsersAjaxController.php
web/app/controllers/UsersController.php
web/app/init.inc.php
web/database.php
web/login.php
web/sysconfig.php
Commit message: Add JWT anti brute-force login protection (#2831)

* WIP: better brute force login protection

* split device token classes

* mv schema to 63

* use int(10) in schema too

* add sysadmin action to clear locked users/devices

* remove the FK on authfail

* remove authfail users_id fk constraint in structure.sql

* catch the invalid device token exception

* remove the banned users stuff

* change invalid token message

* cleanup the exceptions a bit

* get rid of the useless InvalidCsrfTokenException

* remove unused js import

* introduce the AuthenticatedUser and AnonymousUser classes

and improve the App and init.inc.php files

* remove the populateFromEmail method from Users

* get rid of the useless SessionAuth

and rearrange init Auth and App

* be more specific about which kind of user can be loaded in App

* change Update class signature

* use init.inc.php in ApiController

* don't store the whole teamconfigarr in app

Vulnerable code:
    "league/flysystem-memory": "^1.0"
    "content-hash": "d008c3e9bbf0caa3a4881a07bed88241",
<?php
declare(strict_types=1);
use function substr;
    public array $teamConfigArr = array();

        $Update = new Update($this->Config, new Sql());
    /**
     * If the current user is authenticated, load Users with an id
     */
    public function loadUser(Users $users): void
        $this->Users = $users;

        // team config
        $Teams = new Teams($this->Users);
        $this->teamConfigArr = $Teams->read(new ContentParams());
    }
    /**
     * Get the lang (in short form like 'en' or 'fr') for the HTML attribute in head.html template
     */
    public function getLangForHtmlAttribute(): string
    {
        $lang = 'en';
        if (isset($this->Users->userData['lang'])) {
            $lang = substr($this->Users->userData['lang'], 0, 2);
        return $lang;
<?php
declare(strict_types=1);
use Elabftw\Services\SessionAuth;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
 * Provide methods to login a user
    private Config $Config;

    private SessionInterface $Session;

    private Request $Request;

    public function __construct(App $app)
        $this->Config = $app->Config;
        $this->Request = $app->Request;
        $this->Session = $app->Session;
    /**
     * Increase the failed attempts counter
     */
    public function increaseFailedAttempt(): void
    {
        if (!$this->Session->has('failed_attempt')) {
            $this->Session->set('failed_attempt', 1);
        } else {
            $n = $this->Session->get('failed_attempt');
            $n++;
            $this->Session->set('failed_attempt', $n);
        }
    }

    /**
     * Check if we need to bother with authentication of current user
     *
     * @return bool True if we are authentified (or if we don't need to be)
     */
    public function needAuth(): bool
    {
        // pages where you don't need to be logged in
        // only the script name, not the path because we use basename() on it
        $nologinArr = array(
            'change-pass.php',
            'index.php',
            'login.php',
            'LoginController.php',
            'metadata.php',
            'register.php',
            'RegisterController.php',
            'RequestHandler.php',
            'ResetPasswordController.php',
        );

        return !in_array(basename($this->Request->getScriptName()), $nologinArr, true);
    }

        // if we are already logged in with the session, skip everything
        // same if we don't need to be authenticated
        if ($this->Session->has('is_auth')) {
            return 'session';
        }

            case 'session':
                return new SessionAuth();
<?php
 * \Elabftw\Elabftw\Csrf.php
 *
declare(strict_types=1);
use Elabftw\Exceptions\InvalidCsrfTokenException;
            throw new InvalidCsrfTokenException();
            throw new DatabaseErrorException('Error with SQL query', 515, $e);
            throw new DatabaseErrorException('Error while executing SQL query.');
            throw new DatabaseErrorException('Error executing query!');
    public function __construct(public Config $Config, private Sql $Sql)
    public function getRequiredSchema(): int
        $currentSchema = (int) $this->Config->configArr['schema'];

        if ($currentSchema !== self::REQUIRED_SCHEMA) {
        $currentSchema = (int) $this->Config->configArr['schema'];

        if ($currentSchema === self::REQUIRED_SCHEMA) {
        if ($currentSchema < 37) {
        if ($currentSchema < 41) {
        while ($currentSchema < self::REQUIRED_SCHEMA) {
            ++$currentSchema;
            $this->Sql->execFile('schema' . (string) ($currentSchema) . '.sql');
            if ($currentSchema === 57) {
use Elabftw\Elabftw\Sql;
        $Update = new Update($Config, new Sql());

        $required = $Update->getRequiredSchema();
            $Update = new Update($Config, new Sql());
        $this->App->Session->remove('failed_attempt');
<?php
declare(strict_types=1);
class DatabaseErrorException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message, $code = 0, Exception $previous = null)
        parent::__construct($message, $code, $previous);
<?php
declare(strict_types=1);
use Exception;

class FilesystemErrorException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message, $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
<?php
declare(strict_types=1);
use Exception;

class IllegalActionException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message, $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
<?php
declare(strict_types=1);
use Exception;

class ImproperActionException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message, $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
<?php
declare(strict_types=1);
class InvalidCredentialsException extends Exception
    public function __construct(?string $message = null)
        if ($message === null) {
            $message = _('Invalid email/password combination.');
        }
        parent::__construct($message, 0);
<?php
/**
 * @package   Elabftw\Elabftw
 * @author    Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @license   https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
 * @see       https://www.elabftw.net Official website
 */
declare(strict_types=1);

namespace Elabftw\Exceptions;

use Exception;

/**
 * When the CSRF token doesn't validate
 */
class InvalidCsrfTokenException extends Exception
{
    /**
     * The message will always be the same here
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message = null, $code = 0, Exception $previous = null)
    {
        $message = _('Your session expired.');
        parent::__construct($message, $code, $previous);
    }
}
<?php
declare(strict_types=1);
<?php
declare(strict_types=1);
use Exception;

class ProcessFailedException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message, $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
<?php
declare(strict_types=1);
<?php
declare(strict_types=1);
class ResourceNotFoundException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message = null, $code = 404, Exception $previous = null)
<?php
declare(strict_types=1);
class UnauthorizedException extends Exception
    /**
     * Redefine the exception so message isn't optional
     *
     * @param string $message
     * @param int $code
     * @param Exception|null $previous
     */
    public function __construct($message = null, $code = 0, Exception $previous = null)
<?php
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
declare(strict_types=1);

namespace Elabftw\Models;

use Elabftw\Elabftw\Db;
use PDO;

/**
 * Deal with login rate limiter
 */
class BannedUsers
{
    protected Db $Db;

    public function __construct(public Config $Config)
    {
        $this->Db = Db::getConnection();
    }

    /**
     * Add a banned user
     *
     * @param string $fingerprint Should be the md5 of IP + useragent
     */
    public function create(string $fingerprint): bool
    {
        $sql = 'INSERT INTO banned_users (fingerprint) VALUES (:fingerprint)';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':fingerprint', $fingerprint);

        return $req->execute();
    }

    /**
     * Select all actively banned users
     */
    public function readAll(): array
    {
        $banTime = date('Y-m-d H:i:s', (int) strtotime('-' . $this->Config->configArr['ban_time'] . ' minutes'));

        $sql = 'SELECT fingerprint FROM banned_users WHERE time > :ban_time';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':ban_time', $banTime);
        $req->execute();

        $res = $req->fetchAll(PDO::FETCH_COLUMN);
        if ($res === false) {
            return array();
        }
        return $res;
    }

    public function clearAll(): bool
    {
        $sql = 'DELETE FROM banned_users';
        $req = $this->Db->prepare($sql);
        return $req->execute();
    }
}
use Elabftw\Elabftw\Sql;
            throw new DatabaseErrorException('Error while executing SQL query.');
        $Update = new Update($this, new Sql());
        $schema = $Update->getRequiredSchema();
            throw new DatabaseErrorException('Error while executing SQL query.');
use Elabftw\Exceptions\DatabaseErrorException;
     *
     * @param string $realName The clean name of the file
     * @param string $longName The sha512 name
     * @param string $hash The hash string of our file
     * @param string|null $comment The file comment
     * @throws DatabaseErrorException
        if ($comment === null) {
            $comment = 'Click to add a comment';
        }
use Elabftw\Exceptions\ResourceNotFoundException;
     * Create a new user. If no password is provided, it's because we create it from SAML.
    public function create(string $email, array $teams, string $firstname = '', string $lastname = '', string $password = '', ?int $group = null, bool $forceValidation = false, bool $normalizeTeams = true, bool $alertAdmin = true): int
    {
        if ($normalizeTeams) {
            $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
        }
    /**
     * Select by email
     */
    public function populateFromEmail(string $email): void
    {
        $sql = 'SELECT userid
            FROM users
            WHERE email = :email AND archived = 0 AND validated = 1 LIMIT 1';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':email', $email);
        $this->Db->execute($req);
        $res = $req->fetchColumn();
        if ($res === false) {
            throw new ResourceNotFoundException(_('Email not found in database!'));
        }
        $this->populate((int) $res);
    }

use Elabftw\Models\Users;
        $Users = new Users();
            $Users->populateFromEmail($email);
            $Users->create($email, $teams, $firstname, $lastname, '', null, true);
            $Users->populateFromEmail($email);
                throw new DatabaseErrorException('Error inserting data in database!');
            throw new InvalidCredentialsException();
            throw new InvalidCredentialsException();
        $Users = new Users();
        $Teams = new Teams($Users);
            $Users->populateFromEmail($this->email);
            $Users = new Users($Users->create($this->email, $teams, $firstname, $lastname, '', null, true));
            throw new InvalidCredentialsException();
        $Users = new Users();
            $Users->populateFromEmail($this->email);
            // if the email is not found, transform the exception in the general invalidcredentials error
        } catch (ResourceNotFoundException) {
            throw new InvalidCredentialsException();
            throw new InvalidCredentialsException();
            throw new InvalidCredentialsException();
        // no need to login again if the session is valid
        if ($this->AuthResponse->isAuthBy === 'session') {
            return;
        }
            throw new InvalidCredentialsException('The code you entered is not valid!');
        $userid = $Teams->Users->create($email, array($user['team']), $firstname, $lastname, $password, null, true, true, false);
        $Users = new Users();
        // user might not exist yet and populateFromEmail() will throw a ResourceNotFoundException
            $Users->populateFromEmail($email);
            $Users = new Users($Users->create($email, $teams, $firstname, $lastname, '', 4, true, false, false));
<?php
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
declare(strict_types=1);

namespace Elabftw\Services;

use Elabftw\Elabftw\AuthResponse;
use Elabftw\Interfaces\AuthInterface;

/**
 * Session auth service. When the session is already active
 */
class SessionAuth implements AuthInterface
{
    private AuthResponse $AuthResponse;

    public function __construct()
    {
        $this->AuthResponse = new AuthResponse('session');
    }

    /**
     * Nothing to do here because anonymous user can't be authenticated!
     */
    public function tryAuth(): AuthResponse
    {
        return $this->AuthResponse;
    }
}


CREATE TABLE `banned_users` (
  `fingerprint` char(32) NOT NULL,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        <option value='1'{{ App.teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ App.teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ App.teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ App.teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ App.teamConfigArr.public_db ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.public_db ? " selected='selected'" }}>{{ 'No'|trans }}</option>
      <input class='form-control col-md-4' type='text' value='{{ App.teamConfigArr.link_name }}' name='link_name' id='link_name' />
      <input class='mb-4 form-control col-md-4' type='text' value='{{ App.teamConfigArr.link_href }}' name='link_href' id='link_href' />
        <option value='1'{{ App.teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='public'{{ App.teamConfigArr.force_canread == 'public' ? " selected='selected'" }}>{{ 'Public'|trans }}</option>
        <option value='organization'{{ App.teamConfigArr.force_canread == 'organization' ? " selected='selected'" }}>{{ 'Organization'|trans }}</option>
        <option value='team'{{ App.teamConfigArr.force_canread == 'team' ? " selected='selected'" }}>{{ 'Team'|trans }}</option>
        <option value='user'{{ App.teamConfigArr.force_canread == 'user' ? " selected='selected'" }}>{{ 'User'|trans }}</option>
        <option value='1'{{ App.teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not App.teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='public'{{ App.teamConfigArr.force_canwrite == 'public' ? " selected='selected'" }}>{{ 'Public'|trans }}</option>
        <option value='organization'{{ App.teamConfigArr.force_canwrite == 'organization' ? " selected='selected'" }}>{{ 'Organization'|trans }}</option>
        <option value='team'{{ App.teamConfigArr.force_canwrite == 'team' ? " selected='selected'" }}>{{ 'Team'|trans }}</option>
        <option value='user'{{ App.teamConfigArr.force_canwrite == 'user' ? " selected='selected'" }}>{{ 'User'|trans }}</option>
        <input class='form-control col-md-4' type='url' placeholder='http://zeitstempel.dfn.de/' value='{{ App.teamConfigArr.stampprovider }}' name='stampprovider' id='stampprovider' />
        <input class='form-control col-md-4' type='text' placeholder='app/dfn-cert/pki.dfn.pem' value='{{ App.teamConfigArr.stampcert }}' name='stampcert' id='stampcert' />
        <input class='form-control col-md-4' autocomplete='off' type='text' value='{{ App.teamConfigArr.stamplogin }}' name='stamplogin' id='stamplogin' />
        {% if App.teamConfigArr.stamppass|length > 1 %}
          {{ App.teamConfigArr.common_template|raw }}
<!-- show message if there is a failed_attempt -->
{% if App.Session.has('failed_attempt') and App.Session.get('failed_attempt') < App.Config.configArr.login_tries %}
  {% set number_of_tries_left = App.Config.configArr.login_tries - App.Session.get('failed_attempt') %}
  {{ 'Number of login attempt left before being banned for %s minutes: %s'|trans|format(
    App.Config.configArr.ban_time,
    number_of_tries_left)|msg('ko') }}
{% endif %}
<html lang='{{ App.getLangForHtmlAttribute }}'>
            <a class='nav-item nav-link' href='{{ App.teamConfigArr.link_href }}' target='_blank' rel='noopener'>{{ App.teamConfigArr.link_name }}</a>
{{ include('failed-attempt.html') }}
  <h3>{{ 'Bruteforce login protection' }}</h3>
  <p id='bannedUsersCount'>{{ '%d users are currently prevented from logging on after too many failed tries.'|trans|format(bannedCount) }}</p>
  <button type='submit' data-action='clear-banned' class='button btn btn-primary'>{{ 'Clear'|trans }}</button>
    -
      message: '#Variable \$Auth might not be defined.*#'
      path: %currentWorkingDirectory%/web
import { Ajax } from './Ajax.class';
    // CLEAR-BANNED
    if (el.matches('[data-action="clear-banned"]')) {
      const AjaxC = new Ajax('bannedusers', '0', 'app/controllers/SysconfigAjaxController.php');
      AjaxC.post('clear-banned').then(json => {
        if (json.res) {
          document.getElementById('bannedUsersCount').innerText = '';
        }
        notif(json);
      });

        image: elabftw/elabimg:dev
use Elabftw\Exceptions\InvalidCredentialsException;
use Monolog\Logger;
use Symfony\Component\HttpFoundation\Session\Session;
        $Request = Request::createFromGlobals();
        $Session = new Session();
        $App = new App($Request, $Session, Config::getConfig(), new Logger('elabftw'), new Csrf($Request, $Session));
        $this->Auth = new Auth($App);
    /*
    public function testCheckCredentials()
        $this->assertEquals($this->Auth->checkCredentials('phpunit@example.com', 'phpunitftw'), 1);
        $this->expectException(InvalidCredentialsException::class);
        $this->Auth->checkCredentials('phpunit@example.com', 'wrong password');
     */

    /*
    public function testLogin()
    {
        $this->assertTrue($this->Auth->login($this->Auth->checkCredentials('phpunit@example.com', 'phpunitftw')));
        //$this->assertFalse($this->Auth->login('toto@yopmail.com', '0'));
    }
     */
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

class BannedUsersTest extends \PHPUnit\Framework\TestCase
{
    private BannedUsers $BannedUsers;

    protected function setUp(): void
    {
        $this->BannedUsers= new BannedUsers(Config::getConfig());
    }

    public function testCreate(): void
    {
        $fingerprint = md5('yep');
        $this->assertTrue($this->BannedUsers->create($fingerprint));
    }

    public function testReadAll(): void
    {
        $this->assertTrue(is_array($this->BannedUsers->readAll()));
    }
}
        $this->expectException(InvalidCredentialsException::class);
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Services;

use Elabftw\Elabftw\AuthResponse;

class SessionAuthTest extends \PHPUnit\Framework\TestCase
{
    public function testTryAuth(): void
    {
        $AuthService = new SessionAuth();
        $authResponse = $AuthService->tryAuth();
        $this->assertInstanceOf(AuthResponse::class, $authResponse);
        $this->assertEquals('session', $authResponse->isAuthBy);
    }
}
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Models\Config;
use Monolog\Logger;
use PDOException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
require_once dirname(__DIR__, 3) . '/config.php';
require_once dirname(__DIR__, 3) . '/vendor/autoload.php';
// create Request object
$Request = Request::createFromGlobals();
$Session = new Session();
$Session->start();
$Request->setSession($Session);
    // INIT APP OBJECT
    // Config::getConfig() will make the first SQL request
    // PDO will throw an exception if the SQL structure is not imported yet
    try {
        $App = new App($Request, $Session, Config::getConfig(), new Logger('elabftw'), new Csrf($Request, $Session));
    } catch (PDOException $e) {
        throw new ImproperActionException('The database structure is not loaded! Did you run the installer?');
    }
use Elabftw\Exceptions\InvalidCsrfTokenException;
    $userid = $App->Users->create(
        '',
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException | PDOException $e) {
 * app/controllers/IdpsController.php
 *
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException | SyntaxError $e) {
<?php
declare(strict_types=1);
use Elabftw\Exceptions\InvalidCsrfTokenException;
    // CSRF
    $App->Csrf->validate();

} catch (ImproperActionException | InvalidCsrfTokenException | InvalidCredentialsException $e) {
    $Auth->increaseFailedAttempt();
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
            if ((!$App->teamConfigArr['deletable_xp'] && !$App->Session->get('is_admin'))
} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException | ResourceNotFoundException | PDOException $e) {
            $App->Users->populateFromEmail($email);
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
use Elabftw\Models\BannedUsers;
    // CLEAR BANNED
    if ($Request->request->has('clear-banned')) {
        $BannedUsers = new BannedUsers($App->Config);
        $BannedUsers->clearAll();
} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
use Elabftw\Exceptions\InvalidCsrfTokenException;
} catch (ImproperActionException | InvalidCsrfTokenException $e) {
use Elabftw\Models\Users;
    // INIT APP OBJECT
    $Auth = new Auth($App);
    if ($Auth->needAuth()) {
        try {
            // this will throw an UnauthorizedException if we don't have a valid auth
            $AuthResponse = $Auth->tryAuth();
            $LoginHelper = new LoginHelper($AuthResponse, $App->Session);
            $LoginHelper->login(false);
        } catch (UnauthorizedException $e) {
            // KICK USER TO LOGOUT PAGE THAT WILL REDIRECT TO LOGIN PAGE
            // maybe we clicked an email link and we want to be redirected to the page upon successful login
            // so we store the url in a cookie expiring in 5 minutes to redirect to it after login
            // don't store a redirect cookie if we have been logged out and the redirect is to a controller page
            if (!stripos($App->Request->getRequestUri(), 'controllers')) {
                $cookieOptions = array(
                    'expires' => time() + 300,
                    'path' => '/',
                    'domain' => '',
                    'secure' => true,
                    'httponly' => true,
                    'samesite' => 'Strict',
                );
                setcookie('redirect', $App->Request->getRequestUri(), $cookieOptions);
            }

            // used by ajax requests to detect a timed out session
            header('X-Elab-Need-Auth: 1');
            // don't send a GET app/logout.php if it's an ajax call because it messes up the jquery ajax
            if ($App->Request->headers->get('X-Requested-With') !== 'XMLHttpRequest') {
                // NO DON'T USE  THE FULL URL HERE BECAUSE IF SERVER IS HTTP it will fail badly
                header('Location: app/logout.php?keep_redirect=1');
                exit;
            }
            throw new UnauthorizedException(_('Your session expired.'));
        }
    // load the Users with a userid if we are auth and not anon
    if ($App->Session->has('is_auth') && $App->Session->get('userid') !== 0) {
        $App->loadUser(new Users(
            $App->Session->get('userid'),
            $App->Session->get('team'),
        ));
    }
    // ANONYMOUS
    if ($App->Session->get('is_anon') === 1) {
        // anon user only has access to a subset of pages
        $allowedPages = array('index.php', 'experiments.php', 'database.php', 'search.php', 'make.php');
        if (!in_array(basename($App->Request->getScriptName()), $allowedPages, true)) {
            throw new ImproperActionException('Anonymous user cannot access this page');
        }
        $Users = new Users();
        $Users->userData['team'] = $App->Session->get('team');
        $App->loadUser($Users);
        // create a fake Users object with default data for anon user
        $App->Users->userData['team'] = $App->Session->get('team');
        $App->Users->userData['limit_nb'] = 15;
        $App->Users->userData['anon'] = true;
        $App->Users->userData['fullname'] = 'Anon Ymous';
        $App->Users->userData['is_admin'] = 0;
        $App->Users->userData['is_sysadmin'] = 0;
        $App->Users->userData['show_team'] = 1;
        $App->Users->userData['show_team_templates'] = 0;
        $App->Users->userData['show_public'] = 0;
        $App->Users->userData['lang'] = $App->Config->configArr['lang'];
        $App->Users->userData['use_isodate'] = '0';
    // START i18n
    // get the lang
    if ($App->Session->has('is_auth') && $App->Session->get('userid') !== 0) {
        // set lang based on user pref
        $locale = $App->Users->userData['lang'] . '.utf8';
    } else {
        // load server configured lang if logged out
        $locale = $App->Config->configArr['lang'] . '.utf8';
    // configure gettext
    $domain = 'messages';
    putenv("LC_ALL=$locale");
    setlocale(LC_ALL, $locale);
    bindtextdomain($domain, dirname(__DIR__, 2) . '/src/langs');
    textdomain($domain);
    // END i18n
} catch (UnauthorizedException $e) {
    // do nothing here, controller will display the error
    if ($App->Session->has('is_anon') && ($App->teamConfigArr['public_db'] === '0')) {
use Elabftw\Models\BannedUsers;
use function in_array;
use function md5;
    // disable login if too much failed_attempts
    $BannedUsers = new BannedUsers($App->Config);
    // get user info
    $fingerprint = md5($App->Request->server->get('REMOTE_ADDR') . $App->Request->server->get('HTTP_USER_AGENT') ?? '');
    if ($App->Session->has('failed_attempt') && $App->Session->get('failed_attempt') >= $App->Config->configArr['login_tries']) {
        // add the user to the banned list
        $BannedUsers->create($fingerprint);

        $App->Session->remove('failed_attempt');
    }

    // Check if we are banned after too much failed login attempts
    if (in_array($fingerprint, $BannedUsers->readAll(), true)) {
        throw new ImproperActionException(_('You cannot login now because of too many failed login attempts.'));
    }

        'BannedUsers' => $BannedUsers,
use function count;
use Elabftw\Models\BannedUsers;
    $BannedUsers = new BannedUsers($App->Config);
        'bannedCount' => count($BannedUsers->readAll()),

Fixed code:
    "league/flysystem-memory": "^1.0",
    "lcobucci/jwt": "^4.1"
    "content-hash": "6d7a458a48a81fc0b01120f038f8f122",
        {
            "name": "lcobucci/clock",
            "version": "2.0.0",
            "source": {
                "type": "git",
                "url": "https://github.com/lcobucci/clock.git",
                "reference": "353d83fe2e6ae95745b16b3d911813df6a05bfb3"
            },
            "dist": {
                "type": "zip",
                "url": "https://api.github.com/repos/lcobucci/clock/zipball/353d83fe2e6ae95745b16b3d911813df6a05bfb3",
                "reference": "353d83fe2e6ae95745b16b3d911813df6a05bfb3",
                "shasum": ""
            },
            "require": {
                "php": "^7.4 || ^8.0"
            },
            "require-dev": {
                "infection/infection": "^0.17",
                "lcobucci/coding-standard": "^6.0",
                "phpstan/extension-installer": "^1.0",
                "phpstan/phpstan": "^0.12",
                "phpstan/phpstan-deprecation-rules": "^0.12",
                "phpstan/phpstan-phpunit": "^0.12",
                "phpstan/phpstan-strict-rules": "^0.12",
                "phpunit/php-code-coverage": "9.1.4",
                "phpunit/phpunit": "9.3.7"
            },
            "type": "library",
            "autoload": {
                "psr-4": {
                    "Lcobucci\\Clock\\": "src"
                }
            },
            "notification-url": "https://packagist.org/downloads/",
            "license": [
                "MIT"
            ],
            "authors": [
                {
                    "name": "Luís Cobucci",
                    "email": "lcobucci@gmail.com"
                }
            ],
            "description": "Yet another clock abstraction",
            "support": {
                "issues": "https://github.com/lcobucci/clock/issues",
                "source": "https://github.com/lcobucci/clock/tree/2.0.x"
            },
            "funding": [
                {
                    "url": "https://github.com/lcobucci",
                    "type": "github"
                },
                {
                    "url": "https://www.patreon.com/lcobucci",
                    "type": "patreon"
                }
            ],
            "time": "2020-08-27T18:56:02+00:00"
        },
        {
            "name": "lcobucci/jwt",
            "version": "4.1.4",
            "source": {
                "type": "git",
                "url": "https://github.com/lcobucci/jwt.git",
                "reference": "71cf170102c8371ccd933fa4df6252086d144de6"
            },
            "dist": {
                "type": "zip",
                "url": "https://api.github.com/repos/lcobucci/jwt/zipball/71cf170102c8371ccd933fa4df6252086d144de6",
                "reference": "71cf170102c8371ccd933fa4df6252086d144de6",
                "shasum": ""
            },
            "require": {
                "ext-hash": "*",
                "ext-json": "*",
                "ext-mbstring": "*",
                "ext-openssl": "*",
                "ext-sodium": "*",
                "lcobucci/clock": "^2.0",
                "php": "^7.4 || ^8.0"
            },
            "require-dev": {
                "infection/infection": "^0.21",
                "lcobucci/coding-standard": "^6.0",
                "mikey179/vfsstream": "^1.6.7",
                "phpbench/phpbench": "^1.0@alpha",
                "phpstan/extension-installer": "^1.0",
                "phpstan/phpstan": "^0.12",
                "phpstan/phpstan-deprecation-rules": "^0.12",
                "phpstan/phpstan-phpunit": "^0.12",
                "phpstan/phpstan-strict-rules": "^0.12",
                "phpunit/php-invoker": "^3.1",
                "phpunit/phpunit": "^9.5"
            },
            "type": "library",
            "autoload": {
                "psr-4": {
                    "Lcobucci\\JWT\\": "src"
                }
            },
            "notification-url": "https://packagist.org/downloads/",
            "license": [
                "BSD-3-Clause"
            ],
            "authors": [
                {
                    "name": "Luís Cobucci",
                    "email": "lcobucci@gmail.com",
                    "role": "Developer"
                }
            ],
            "description": "A simple library to work with JSON Web Token and JSON Web Signature",
            "keywords": [
                "JWS",
                "jwt"
            ],
            "support": {
                "issues": "https://github.com/lcobucci/jwt/issues",
                "source": "https://github.com/lcobucci/jwt/tree/4.1.4"
            },
            "funding": [
                {
                    "url": "https://github.com/lcobucci",
                    "type": "github"
                },
                {
                    "url": "https://www.patreon.com/lcobucci",
                    "type": "patreon"
                }
            ],
            "time": "2021-03-23T23:53:08+00:00"
        },
<?php declare(strict_types=1);
use function basename;
use function bindtextdomain;
use function dirname;
use Elabftw\Exceptions\UnauthorizedException;
use Elabftw\Models\AnonymousUser;
use Elabftw\Models\AuthenticatedUser;
use function in_array;
use function putenv;
use function setlocale;
use function textdomain;
    public string $linkName = 'Documentation';

    public string $linkHref = 'https://doc.elabftw.net';

        $Update = new Update((int) $this->Config->configArr['schema'], new Sql());
    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
    //     _                 _      //
    //    | |__   ___   ___ | |_    //
    //    | '_ \ / _ \ / _ \| __|   //
    //    | |_) | (_) | (_) | |_    //
    //    |_.__/ \___/ \___/ \__|   //
    //                              //
    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
    public function boot(): void
        // load the Users with a userid if we are auth and not anon
        if ($this->Session->has('is_auth') && $this->Session->get('userid') !== 0) {
            $this->loadUser(new AuthenticatedUser(
                $this->Session->get('userid'),
                $this->Session->get('team'),
            ));
        }
        // ANONYMOUS
        if ($this->Session->get('is_anon') === 1) {
            // anon user only has access to a subset of pages
            $allowedPages = array('index.php', 'experiments.php', 'database.php', 'search.php', 'make.php');
            if (!in_array(basename($this->Request->getScriptName()), $allowedPages, true)) {
                throw new UnauthorizedException();
            }

            $this->loadUser(new AnonymousUser(
                $this->Session->get('team'),
                $this->getLang(),
            ));
        $this->initi18n();

    /**
     * Get the lang of our current user
     */
    public function getLang(): string
    {
        // if we have an authenticated user, use their lang setting
        if ($this->Users instanceof AuthenticatedUser) {
            return $this->Users->userData['lang'];
        }
        // default lang is the server configured one
        return $this->Config->configArr['lang'];
    }

    /**
     * Load a user object in our user field
     */
    private function loadUser(AuthenticatedUser | AnonymousUser $users): void
    {
        $this->Users = $users;
        // we have an user in a team, load the top menu link
        $Teams = new Teams($this->Users);
        $teamConfigArr = $Teams->read(new ContentParams());
        $this->linkName = $teamConfigArr['link_name'];
        $this->linkHref = $teamConfigArr['link_href'];
    }

    /**
     * Configure gettext domain
     */
    private function initi18n(): void
    {
        $locale = $this->getLang() . '.utf8';
        $domain = 'messages';
        putenv("LC_ALL=$locale");
        setlocale(LC_ALL, $locale);
        bindtextdomain($domain, dirname(__DIR__, 2) . '/src/langs');
        textdomain($domain);
    }
<?php declare(strict_types=1);
 * Provide methods to authenticate a user
    public function __construct(private Config $Config, private Request $Request)
<?php declare(strict_types=1);
use Elabftw\Exceptions\ImproperActionException;
            // an invalid csrf token is most likely the result of an expired session
            throw new ImproperActionException(_('Your session expired.'));
            throw new DatabaseErrorException($e);
            throw new DatabaseErrorException();
            throw new DatabaseErrorException();
    public function __construct(private int $currentSchema, private Sql $Sql)
    public static function getRequiredSchema(): int
        if ($this->currentSchema !== self::REQUIRED_SCHEMA) {
        if ($this->currentSchema === self::REQUIRED_SCHEMA) {
        if ($this->currentSchema < 37) {
        if ($this->currentSchema < 41) {
        while ($this->currentSchema < self::REQUIRED_SCHEMA) {
            ++$this->currentSchema;
            $this->Sql->execFile('schema' . (string) ($this->currentSchema) . '.sql');
            if ($this->currentSchema === 57) {

        $required = Update::getRequiredSchema();
            $Update = new Update((int) $Config->configArr['schema'], new Sql());
use Elabftw\Exceptions\InvalidCredentialsException;
use Elabftw\Exceptions\ResourceNotFoundException;
use Elabftw\Models\ExistingUser;
use Elabftw\Services\DeviceToken;
use Elabftw\Services\DeviceTokenValidator;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
        $App->Csrf->validate();
        $this->validateDeviceToken();
    /**
     * See https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
     */
    private function validateDeviceToken(): void
    {
        // a devicetoken cookie might or might not exist, so this can be null
        $token = $this->App->Request->cookies->get('devicetoken');
        // nothing to do if no device token is sent along
        if (!is_string($token)) {
            return;
        }
        $DeviceTokenValidator = new DeviceTokenValidator(DeviceToken::getConfig(), $token);
        try {
            $DeviceTokenValidator->validate();
        } catch (RequiredConstraintsViolated $e) {
            // our device token is not valid
            // we need to check if we can allow untrusted devices to login for that user
            // figure out what is the userid from the email sent with the request
            // might throw ResourceNotFoundException if the user doesn't exist
            $Users = ExistingUser::fromEmail((string) $this->App->Request->request->get('email'));
            // check if authentication is locked for untrusted clients for that user
            if ($Users->userData['allow_untrusted'] === '0') {
                // reject any attempt whatsoever if this account is locked for untrusted devices
                throw new InvalidCredentialsException((int) $Users->userData['userid']);
            }
            // here the devicetoken is invalid but we allow to continue auth because account is not locked for untrusted devices
        }
    }

<?php declare(strict_types=1);
final class DatabaseErrorException extends Exception
    public function __construct(?Exception $previous = null)
        parent::__construct('An error occured during the execution of the SQL query.', 515, $previous);
<?php declare(strict_types=1);
final class FilesystemErrorException extends WithMessageException
<?php declare(strict_types=1);
final class IllegalActionException extends WithMessageException
<?php declare(strict_types=1);
final class ImproperActionException extends WithMessageException
<?php declare(strict_types=1);
final class InvalidCredentialsException extends Exception
    public function __construct(int $userid)
        parent::__construct(_('Invalid email/password combination.'), $userid);
<?php declare(strict_types=1);
/**
 * @package   Elabftw\Elabftw
 * @author    Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @license   https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
 * @see       https://www.elabftw.net Official website
 */

namespace Elabftw\Exceptions;

use Exception;

/**
 * Throw this if the device token is not valid
 */
class InvalidDeviceTokenException extends Exception
{
    public function __construct()
    {
        parent::__construct(_('This browser cannot login anymore because of too many failed attempts.'));
    }
}
<?php declare(strict_types=1);
<?php declare(strict_types=1);
final class ProcessFailedException extends WithMessageException
<?php declare(strict_types=1);
<?php declare(strict_types=1);
final class ResourceNotFoundException extends Exception
    public function __construct(string $message = null, int $code = 404, Exception $previous = null)
<?php declare(strict_types=1);
final class UnauthorizedException extends Exception
    public function __construct(string $message = null, int $code = 0, Exception $previous = null)
<?php declare(strict_types=1);
/**
 * @package   Elabftw\Elabftw
 * @author    Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @license   https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
 * @see       https://www.elabftw.net Official website
 */

namespace Elabftw\Exceptions;

use Exception;

/**
 * Base class for exceptions with customizable message
 */
class WithMessageException extends Exception
{
    public function __construct(string $message, int $code = 0, Exception $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

/**
 * An anonymous user is "logged in" in a team but doesn't have a userid
 */
final class AnonymousUser extends Users
{
    public function __construct(public int $team, private string $lang)
    {
        parent::__construct(null, $team);
        $this->fillUserData();
    }

    private function fillUserData(): void
    {
        $this->userData['team'] = $this->team;
        $this->userData['limit_nb'] = 15;
        $this->userData['anon'] = true;
        $this->userData['fullname'] = 'Anon Ymous';
        $this->userData['is_admin'] = 0;
        $this->userData['is_sysadmin'] = 0;
        $this->userData['show_team'] = 1;
        $this->userData['show_team_templates'] = 0;
        $this->userData['show_public'] = 0;
        $this->userData['lang'] = $this->lang;
        $this->userData['use_isodate'] = '0';
    }
}
<?php
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
declare(strict_types=1);

namespace Elabftw\Models;

use Elabftw\Elabftw\Db;
use PDO;

/**
 * Keep track of authentication failures and lock devices or users
 */
class AuthFail
{
    protected Db $Db;

    public function __construct(private int $loginTries = 0, private int $userid = 0, private ?string $deviceToken = null)
    {
        $this->Db = Db::getConnection();
    }

    /**
     * Register a failed auth attempt, and possibly lock user or device
     */
    public function register(): bool
    {
        $this->create();
        if ($this->deviceToken === null) {
            return $this->countAndLockUser();
        }
        return $this->countAndLockDevice();
    }

    public function getLockoutDevicesCount(): int
    {
        $sql = 'SELECT COUNT(id) FROM lockout_devices WHERE locked_at > (NOW() - INTERVAL 1 HOUR)';
        $req = $this->Db->prepare($sql);
        $req->execute();
        return (int) $req->fetchColumn();
    }

    /**
     * No device token, count login attempts for that user and lock account for further untrusted devices attempts
     */
    private function countAndLockUser(): bool
    {
        if ($this->countUserAttempts() > $this->loginTries) {
            return $this->lockUserAuth();
        }
        return false;
    }

    /**
     * When device token is present, prevent further login attempts from it if needed
     */
    private function countAndLockDevice(): bool
    {
        if ($this->countDeviceAttempts() > $this->loginTries) {
            return $this->lockDevice();
        }
        return false;
    }

    private function lockDevice(): bool
    {
        $sql = 'INSERT INTO lockout_devices (device_token) VALUES (:device_token)';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':device_token', $this->deviceToken, PDO::PARAM_STR);
        return $req->execute();
    }

    /**
     * Register a failed attempt
     */
    private function create(): bool
    {
        $sql = 'INSERT INTO authfail (users_id, device_token) VALUES (:users_id, :device_token)';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':users_id', $this->userid, PDO::PARAM_INT);
        $req->bindParam(':device_token', $this->deviceToken, PDO::PARAM_STR);
        return $req->execute();
    }

    /**
     * Count the number of times this device failed to login
     */
    private function countDeviceAttempts(): int
    {
        $sql = 'SELECT COUNT(id) FROM authfail WHERE device_token = :device_token AND attempt_time > (NOW() - INTERVAL 1 HOUR)';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':device_token', $this->deviceToken, PDO::PARAM_STR);
        $req->execute();
        return (int) $req->fetchColumn();
    }

    /**
     * Count the number of times this user failed to login
     */
    private function countUserAttempts(): int
    {
        $sql = 'SELECT COUNT(id) FROM authfail WHERE users_id = :users_id AND attempt_time > (NOW() - INTERVAL 1 HOUR)';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':users_id', $this->userid, PDO::PARAM_INT);
        $req->execute();
        return (int) $req->fetchColumn();
    }

    /**
     * Prevent all authentication attempts from untrusted devices for a user
     */
    private function lockUserAuth(): bool
    {
        $sql = 'UPDATE users SET allow_untrusted = "0", auth_lock_time = NOW() WHERE userid = :userid';
        $req = $this->Db->prepare($sql);
        $req->bindParam(':userid', $this->userid, PDO::PARAM_INT);
        return $this->Db->execute($req);
    }
}
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

/**
 * An authenticated user
 */
final class AuthenticatedUser extends ExistingUser
{
    public function __construct(public int $userid, public int $team)
    {
        parent::__construct($userid, $team);
    }
}
            throw new DatabaseErrorException();
        $schema = Update::getRequiredSchema();
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

use Elabftw\Elabftw\Db;
use Elabftw\Exceptions\ResourceNotFoundException;

/**
 * A user that exists in the db, so we have a userid but not necessarily a team
 */
class ExistingUser extends Users
{
    public static function fromEmail(string $email): Users
    {
        $Db = Db::getConnection();
        $sql = 'SELECT userid FROM users
            WHERE email = :email AND archived = 0 AND validated = 1 LIMIT 1';
        $req = $Db->prepare($sql);
        $req->bindParam(':email', $email);
        $Db->execute($req);
        $res = $req->fetchColumn();
        if ($res === false) {
            throw new ResourceNotFoundException();
        }
        return new self((int) $res);
    }

    public static function fromScratch(
        string $email,
        array $teams,
        string $firstname,
        string $lastname,
        ?int $usergroup = null,
        bool $forceValidation = false,
        bool $alertAdmin = true,
    ): Users {
        $Users = new self();
        $userid = $Users->create($email, $teams, $firstname, $lastname, '', $usergroup, $forceValidation, $alertAdmin);
        $Users->populate($userid);
        return $Users;
    }
}
            throw new DatabaseErrorException();
        $comment ??= 'Click to add a comment';
     * Create a new user
    public function create(
        string $email,
        array $teams,
        string $firstname = '',
        string $lastname = '',
        string $password = '',
        ?int $group = null,
        bool $forceValidation = false,
        bool $alertAdmin = true,
    ): int {
        $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
    public function getLockedUsersCount(): int
    {
        $sql = 'SELECT COUNT(userid) FROM users WHERE allow_untrusted = 0';
        $req = $this->Db->prepare($sql);
        $this->Db->execute($req);
        return (int) $req->fetchColumn();
    }

<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

use Elabftw\Elabftw\Db;

/**
 * A user that exists in the db, so we have a userid but not necessarily a team
 */
class ValidatedUser extends ExistingUser
{
    public static function fromExternal(string $email, array $teams, string $firstname, string $lastname): Users
    {
        return parent::fromScratch($email, $teams, $firstname, $lastname, null, true);
    }

    public static function fromAdmin(string $email, array $teams, string $firstname, string $lastname, int $usergroup): Users
    {
        return parent::fromScratch($email, $teams, $firstname, $lastname, $usergroup, true, false);
    }
}
<?php
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
declare(strict_types=1);

namespace Elabftw\Services;

use DateTimeImmutable;
use Defuse\Crypto\Key;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
use Lcobucci\JWT\Validation\Constraint\SignedWith;

/**
 * DeviceToken generator
 */
class DeviceToken
{
    public static function getToken(int $userid): string
    {
        $now = new DateTimeImmutable();
        $config = self::getConfig();
        $token = $config->builder()
                // Configures the issuer (iss claim)
                //->issuedBy('https://elab.local:3148')
                // Configures the audience (aud claim)
                ->permittedFor('brute-force-protection')
                // Configures the id (jti claim)
                //->identifiedBy('4f1g23a12aa')
                // Configures the time that the token was issue (iat claim)
                ->issuedAt($now)
                // Configures the time that the token can be used (nbf claim)
                //->canOnlyBeUsedAfter($now->modify('+1 minute'))
                // Configures the expiration time of the token (exp claim)
                ->expiresAt($now->modify('+3 months'))
                // Configures a new claim, called "uid"
                ->withClaim('userid', $userid)
                // Builds a new token
                ->getToken($config->signer(), $config->signingKey());
        return $token->toString();
    }

    public static function getConfig(): Configuration
    {
        $secretKey = Key::loadFromAsciiSafeString(\SECRET_KEY);
        $config = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::plainText($secretKey->getRawBytes()),
        );
        // TODO validate the userid claim and other stuff
        $config->setValidationConstraints(new PermittedFor('brute-force-protection'));
        $config->setValidationConstraints(new SignedWith($config->signer(), $config->signingKey()));
        return $config;
    }
}
<?php
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
declare(strict_types=1);

namespace Elabftw\Services;

use Elabftw\Elabftw\Db;
use Elabftw\Exceptions\InvalidDeviceTokenException;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;

/**
 * Validate a device token
 */
class DeviceTokenValidator
{
    public function __construct(private Configuration $config, private string $deviceToken)
    {
    }

    public function validate(): void
    {
        $Db = Db::getConnection();
        try {
            $parsedToken = $this->config->parser()->parse($this->deviceToken);
            $this->config->validator()->assert($parsedToken, ...$this->config->validationConstraints());
            // also check if the device token is not in the locklist
            $sql = 'SELECT COUNT(id) FROM lockout_devices WHERE device_token = :device_token AND locked_at > (NOW() - INTERVAL 1 HOUR)';
            $req = $Db->prepare($sql);
            $req->bindParam(':device_token', $this->deviceToken);
            $req->execute();
            if ($req->fetchColumn() > 0) {
                throw new InvalidDeviceTokenException();
            }
            // group all the possible exceptions into one because we don't really care the reason why the token might be invalid
        } catch (CannotDecodeContent | InvalidTokenStructure | RequiredConstraintsViolated $e) {
            throw new InvalidDeviceTokenException();
        }
    }
}
use Elabftw\Models\ExistingUser;
use Elabftw\Models\ValidatedUser;
            $Users = ExistingUser::fromEmail($email);
            $Users = ValidatedUser::fromExternal($email, $teams, $firstname, $lastname);
                throw new DatabaseErrorException();
use Elabftw\Models\ExistingUser;
use Elabftw\Models\ValidatedUser;
            throw new InvalidCredentialsException(0);
            throw new InvalidCredentialsException(0);
        $Teams = new Teams(new Users());
            $Users = ExistingUser::fromEmail($this->email);
            $Users = ValidatedUser::fromExternal($this->email, $teams, $firstname, $lastname);
use Elabftw\Exceptions\ImproperActionException;
use Elabftw\Exceptions\QuantumException;
use Elabftw\Models\ExistingUser;
            throw new ImproperActionException('Could not find salt!');
            $Users = ExistingUser::fromEmail($this->email);
        } catch (ResourceNotFoundException $e) {
            // here we rethrow an quantum exception because we don't want to let the user know if the email exists or not
            throw new QuantumException(_('Invalid email/password combination.'));
            throw new InvalidCredentialsException($this->userid);
            throw new InvalidCredentialsException($this->userid);
        $this->setDeviceToken();
    private function setDeviceToken(): void
    {
        // set device token as a cookie
        $cookieOptions = array(
            'expires' => time() + 2592000,
            'path' => '/',
            'domain' => '',
            'secure' => true,
            'httponly' => true,
            'samesite' => 'Strict',
        );

        setcookie('devicetoken', DeviceToken::getToken($this->AuthResponse->userid), $cookieOptions);
    }

            throw new InvalidCredentialsException($this->MfaHelper->userid);
        $userid = $Teams->Users->create($email, array($user['team']), $firstname, $lastname, $password, null, true, false);
use Elabftw\Models\ExistingUser;
use Elabftw\Models\ValidatedUser;
            $Users = ExistingUser::fromEmail($email);
            $Users = ValidatedUser::fromExternal($email, $teams, $firstname, $lastname);
    -- No FK on users_id because it might be 0 if we cannot get a userid for the auth try (user doesn't exist)
    CREATE TABLE `authfail` ( `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `users_id` INT(10) UNSIGNED NOT NULL , `attempt_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `device_token` VARCHAR(255) NULL DEFAULT NULL, PRIMARY KEY (`id`));
    ALTER TABLE `users` ADD `allow_untrusted` tinyint(1) UNSIGNED NOT NULL DEFAULT '1';
    ALTER TABLE `users` ADD `auth_lock_time` DATETIME NULL DEFAULT NULL;
    CREATE TABLE `lockout_devices` ( `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `locked_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `device_token` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`));
    DROP TABLE `banned_users`;
-- Table structure for table `authfail`
CREATE TABLE `authfail` (
  `users_id` int(10) UNSIGNED NOT NULL,
  `attempt_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `device_token` varchar(255) DEFAULT NULL,
-- RELATIONSHIPS FOR TABLE `api_keys`:
--   `userid`
--       `users` -> `userid`
--   `team`
--       `teams` -> `id`
--
-- Table structure for table `lockout_devices`
--

CREATE TABLE `lockout_devices` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
  `locked_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `device_token` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

  `allow_untrusted` tinyint(1) UNSIGNED NOT NULL DEFAULT '1',
  `auth_lock_time` datetime DEFAULT NULL,
        <option value='1'{{ teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='1'{{ teamConfigArr.public_db ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.public_db ? " selected='selected'" }}>{{ 'No'|trans }}</option>
      <input class='form-control col-md-4' type='text' value='{{ teamConfigArr.link_name }}' name='link_name' id='link_name' />
      <input class='mb-4 form-control col-md-4' type='text' value='{{ teamConfigArr.link_href }}' name='link_href' id='link_href' />
        <option value='1'{{ teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='public'{{ teamConfigArr.force_canread == 'public' ? " selected='selected'" }}>{{ 'Public'|trans }}</option>
        <option value='organization'{{ teamConfigArr.force_canread == 'organization' ? " selected='selected'" }}>{{ 'Organization'|trans }}</option>
        <option value='team'{{ teamConfigArr.force_canread == 'team' ? " selected='selected'" }}>{{ 'Team'|trans }}</option>
        <option value='user'{{ teamConfigArr.force_canread == 'user' ? " selected='selected'" }}>{{ 'User'|trans }}</option>
        <option value='1'{{ teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
        <option value='0'{{ not teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'No'|trans }}</option>
        <option value='public'{{ teamConfigArr.force_canwrite == 'public' ? " selected='selected'" }}>{{ 'Public'|trans }}</option>
        <option value='organization'{{ teamConfigArr.force_canwrite == 'organization' ? " selected='selected'" }}>{{ 'Organization'|trans }}</option>
        <option value='team'{{ teamConfigArr.force_canwrite == 'team' ? " selected='selected'" }}>{{ 'Team'|trans }}</option>
        <option value='user'{{ teamConfigArr.force_canwrite == 'user' ? " selected='selected'" }}>{{ 'User'|trans }}</option>
        <input class='form-control col-md-4' type='url' placeholder='http://zeitstempel.dfn.de/' value='{{ teamConfigArr.stampprovider }}' name='stampprovider' id='stampprovider' />
        <input class='form-control col-md-4' type='text' placeholder='app/dfn-cert/pki.dfn.pem' value='{{ teamConfigArr.stampcert }}' name='stampcert' id='stampcert' />
        <input class='form-control col-md-4' autocomplete='off' type='text' value='{{ teamConfigArr.stamplogin }}' name='stamplogin' id='stamplogin' />
        {% if teamConfigArr.stamppass|length > 1 %}
          {{ teamConfigArr.common_template|raw }}
<html lang='{{ App.getLang() | slice(0, 2) }}'>
            <a class='nav-item nav-link' href='{{ App.linkHref }}' target='_blank' rel='noopener'>{{ App.linkName }}</a>
  <div id='bruteforceDiv'>
    <h3>{{ 'Bruteforce login protection' }}</h3>
    <div class='mb-1'>
      <span>{{ '%d users are currently prevented from logging in from unknown devices.'|trans|format(nologinUsersCount) }}</span>
      <button type='submit' data-action='clear-nologinusers' {{ nologinUsersCount == 0 ? 'disabled' }} class='button btn btn-primary'>{{ 'Clear'|trans }}</button>

    </div>

    <div>
      <span>{{ '%d devices are currently denied authentication.'|trans|format(lockoutDevicesCount) }}</span>
      <button type='submit' data-action='clear-lockoutdevices' {{ lockoutDevicesCount == 0 ? 'disabled' }} class='button btn btn-primary'>{{ 'Clear'|trans }}</button>
    </div>
  </div>
    // CLEAR-LOCKEDUSERS and CLEAR-LOCKOUTDEVICES
    if (el.matches('[data-action="clear-nologinusers"]') || el.matches('[data-action="clear-lockoutdevices"]')) {
      const formData  = new FormData();
      formData.append(el.dataset.action, 'yep');
      formData.append('csrf', document.querySelector('meta[name="csrf-token"]').getAttribute('content'));
      fetch('app/controllers/SysconfigAjaxController.php', {
        method: 'POST',
        body: formData,
      }).then(response => response.json())
        .then(json => {
          if (json.res) {
            $('#bruteforceDiv').load('sysconfig.php #bruteforceDiv > *');
          }
          notif(json);
        });
        image: elabftw/elabimg:hypernext
            - DEV_MODE=true
use Elabftw\Exceptions\UnauthorizedException;
        $this->Auth = new Auth(Config::getConfig(), Request::createFromGlobals());
    public function testTryAuth(): void
        $this->expectException(UnauthorizedException::class);
        $this->Auth->tryAuth();
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Models;

use Elabftw\Services\DeviceToken;

class AuthFailTest extends \PHPUnit\Framework\TestCase
{
    public function testRegisterWithoutDeviceToken(): void
    {
        $AuthFail = new AuthFail(10, 1);
        $AuthFail->register();
    }

    public function testRegisterWithDeviceToken(): void
    {
        $DeviceToken = new DeviceToken();
        $deviceToken = $DeviceToken->getToken(1);
        $AuthFail = new AuthFail(10, 1, $deviceToken);
        $AuthFail->register();
    }

    public function testLockDevice(): void
    {
        $DeviceToken = new DeviceToken();
        $deviceToken = $DeviceToken->getToken(1);
        $AuthFail = new AuthFail(0, 1, $deviceToken);
        $AuthFail->register();
    }

    public function testLockUser(): void
    {
        $AuthFail = new AuthFail(0, 1);
        $AuthFail->register();
    }
}
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Services;

class DeviceTokenTest extends \PHPUnit\Framework\TestCase
{
    public function testGetToken(): void
    {
        $this->assertIsString(DeviceToken::getToken(1));
    }
}
<?php declare(strict_types=1);
/**
 * @author Nicolas CARPi <nico-git@deltablot.email>
 * @copyright 2012 Nicolas CARPi
 * @see https://www.elabftw.net Official website
 * @license AGPL-3.0
 * @package elabftw
 */

namespace Elabftw\Services;

use Elabftw\Exceptions\InvalidDeviceTokenException;
use Lcobucci\JWT\Configuration;

class DeviceTokenValidatorTest extends \PHPUnit\Framework\TestCase
{
    private Configuration $config;

    protected function setUp(): void
    {
        $this->config = DeviceToken::getConfig();
    }

    public function testValidateValidToken(): void
    {
        $validToken = DeviceToken::getToken(1);
        $DeviceTokenValidator = new DeviceTokenValidator($this->config, $validToken);
        $DeviceTokenValidator->validate();
    }

    public function testUndecodableToken(): void
    {
        $DeviceTokenValidator = new DeviceTokenValidator($this->config, '..');
        $this->expectException(InvalidDeviceTokenException::class);
        $DeviceTokenValidator->validate();
    }

    public function testNotParsableToken(): void
    {
        $DeviceTokenValidator = new DeviceTokenValidator($this->config, 'this cannot be parsed!');
        $this->expectException(InvalidDeviceTokenException::class);
        $DeviceTokenValidator->validate();
    }
}
use Elabftw\Exceptions\QuantumException;
        $this->expectException(QuantumException::class);
    $teamConfigArr = $Teams->read(new ContentParams());
        'teamConfigArr' => $teamConfigArr,
} catch (ImproperActionException $e) {
require_once dirname(__DIR__) . '/init.inc.php';
use Elabftw\Models\ValidatedUser;
    $Users = ValidatedUser::fromAdmin(
} catch (ImproperActionException $e) {
} catch (ImproperActionException | UnauthorizedException | PDOException $e) {
} catch (ImproperActionException | SyntaxError $e) {
<?php declare(strict_types=1);
use Elabftw\Exceptions\InvalidDeviceTokenException;
use Elabftw\Models\AuthFail;
} catch (InvalidCredentialsException $e) {
    $loginTries = (int) $App->Config->configArr['login_tries'];
    $AuthFail = new AuthFail($loginTries, $e->getCode(), $App->Request->cookies->get('devicetoken'));
    $AuthFail->register();
    $App->Session->getFlashBag()->add('ko', $e->getMessage());
} catch (ImproperActionException | InvalidDeviceTokenException $e) {
} catch (ImproperActionException $e) {
use Elabftw\Models\Teams;
            $Teams = new Teams($App->Users);
            $teamConfigArr = $Teams->read(new ContentParams());
            if ((!$teamConfigArr['deletable_xp'] && !$App->Session->get('is_admin'))
} catch (ImproperActionException | UnauthorizedException | ResourceNotFoundException | PDOException $e) {
use Elabftw\Models\ExistingUser;
            $App->Users = ExistingUser::fromEmail($email);
} catch (ImproperActionException | UnauthorizedException $e) {
    // CLEAR NOLOGIN
    if ($Request->request->has('clear-nologinusers')) {
        // this is so simple and only used here it doesn't have its own function
        $Db = Db::getConnection();
        $Db->q('UPDATE users SET allow_untrusted = 1');

    // CLEAR LOCKOUT DEVICES
    if ($Request->request->has('clear-lockoutdevices')) {
        // this is so simple and only used here it doesn't have its own function
        $Db = Db::getConnection();
        $Db->q('DELETE FROM lockout_devices');
    }
} catch (ImproperActionException | UnauthorizedException $e) {
} catch (ImproperActionException $e) {
} catch (ImproperActionException $e) {
} catch (ImproperActionException | UnauthorizedException $e) {
} catch (ImproperActionException $e) {
use function header;
use function stripos;
    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
    //     _                 _      //
    //    | |__   ___   ___ | |_    //
    //    | '_ \ / _ \ / _ \| __|   //
    //    | |_) | (_) | (_) | |_    //
    //    |_.__/ \___/ \___/ \__|   //
    //                              //
    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
    // pages where you don't need to be logged in
    // only the script name, not the path because we use basename() on it
    $nologinArr = array(
        'ApiController.php',
        'change-pass.php',
        'index.php',
        'login.php',
        'LoginController.php',
        'metadata.php',
        'register.php',
        'RegisterController.php',
        'RequestHandler.php',
        'ResetPasswordController.php',
    );
    if (!in_array(basename($Request->getScriptName()), $nologinArr, true) && !$Session->has('is_auth')) {
        // try to login our user with session, cookie or other method not requiring a login action
        $Auth = new Auth($App->Config, $Request);
        // this will throw an UnauthorizedException if we don't have a valid auth
        $AuthResponse = $Auth->tryAuth();
        $LoginHelper = new LoginHelper($AuthResponse, $Session);
        $LoginHelper->login(false);
    $App->boot();
} catch (UnauthorizedException $e) {
    // KICK USER TO LOGOUT PAGE THAT WILL REDIRECT TO LOGIN PAGE
    // maybe we clicked an email link and we want to be redirected to the page upon successful login
    // so we store the url in a cookie expiring in 5 minutes to redirect to it after login
    // don't store a redirect cookie if we have been logged out and the redirect is to a controller page
    if (!stripos($Request->getRequestUri(), 'controllers')) {
        $cookieOptions = array(
            'expires' => time() + 300,
            'path' => '/',
            'domain' => '',
            'secure' => true,
            'httponly' => true,
            'samesite' => 'Strict',
        );
        setcookie('redirect', $Request->getRequestUri(), $cookieOptions);
    // used by ajax requests to detect a timed out session
    header('X-Elab-Need-Auth: 1');
    // don't send a GET app/logout.php if it's an ajax call because it messes up the jquery ajax
    if ($Request->headers->get('X-Requested-With') !== 'XMLHttpRequest') {
        // NO DON'T USE  THE FULL URL HERE BECAUSE IF SERVER IS HTTP it will fail badly
        header('Location: app/logout.php?keep_redirect=1');
    exit;
use Elabftw\Models\Teams;
    $Teams = new Teams($App->Users);
    $teamConfigArr = $Teams->read(new ContentParams());
    if ($App->Session->has('is_anon') && ($teamConfigArr['public_db'] === '0')) {
use Elabftw\Models\AuthFail;
    $AuthFail = new AuthFail();
        'nologinUsersCount' => $App->Users->getLockedUsersCount(),
        'lockoutDevicesCount' => $AuthFail->getLockoutDevicesCount(),