CVE-2021-41171: Improper Restriction of Excessive Authentication Attempts (CWE-307)
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.
CWE description: 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.
CVSS v3 base score: 8.8
Published: 2021-10-22T19:15Z
Repository: https://github.com/elabftw/elabftw
Language: PHP
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 date: 2021-08-08 21:13:04 +0200
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
Diff: diff --git a/composer.json b/composer.json
index 64a56dd1b..62bcc3dd9 100644
--- a/composer.json
+++ b/composer.json
@@ -36,11 +36,12 @@
     "symfony/filesystem": "^5.0",
     "symfony/mime": "^5.1",
     "robthree/twofactorauth": "^1.7.0",
     "directorytree/ldaprecord": "^2.0.3",
     "league/flysystem": "^1.1",
-    "league/flysystem-memory": "^1.0"
+    "league/flysystem-memory": "^1.0",
+    "lcobucci/jwt": "^4.1"
   },
   "require-dev": {
     "roave/security-advisories": "dev-master",
     "codeception/codeception": "^4.0",
     "pyrech/composer-changelogs": "1.7.*",
diff --git a/composer.lock b/composer.lock
index 972ce4332..f5e9e4e46 100644
--- a/composer.lock
+++ b/composer.lock
@@ -2,11 +2,11 @@
     "_readme": [
         "This file locks the dependencies of your project to a known state",
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "d008c3e9bbf0caa3a4881a07bed88241",
+    "content-hash": "6d7a458a48a81fc0b01120f038f8f122",
     "packages": [
         {
             "name": "defuse/php-encryption",
             "version": "v2.3.1",
             "source": {
@@ -572,10 +572,145 @@
                 "issues": "https://github.com/laravel/framework/issues",
                 "source": "https://github.com/laravel/framework"
             },
             "time": "2021-06-01T14:53:38+00:00"
         },
+        {
+            "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"
+        },
         {
             "name": "league/commonmark",
             "version": "1.6.6",
             "source": {
                 "type": "git",
diff --git a/src/classes/App.php b/src/classes/App.php
index bbb220337..259a73600 100644
--- a/src/classes/App.php
+++ b/src/classes/App.php
@@ -1,29 +1,37 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
+use function basename;
+use function bindtextdomain;
+use function dirname;
+use Elabftw\Exceptions\UnauthorizedException;
+use Elabftw\Models\AnonymousUser;
+use Elabftw\Models\AuthenticatedUser;
 use Elabftw\Models\Config;
 use Elabftw\Models\Teams;
 use Elabftw\Models\Users;
 use Elabftw\Services\Check;
 use Elabftw\Traits\TwigTrait;
 use Elabftw\Traits\UploadTrait;
+use function in_array;
 use Monolog\Handler\ErrorLogHandler;
 use Monolog\Logger;
-use function substr;
+use function putenv;
+use function setlocale;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Session\Flash\FlashBag;
 use Symfony\Component\HttpFoundation\Session\SessionInterface;
+use function textdomain;
 
 /**
  * This is a super class holding various global objects
  */
 class App
@@ -35,18 +43,20 @@ class App
 
     public Users $Users;
 
     public string $pageTitle = 'Lab manager';
 
+    public string $linkName = 'Documentation';
+
+    public string $linkHref = 'https://doc.elabftw.net';
+
     public array $ok = array();
 
     public array $ko = array();
 
     public array $warning = array();
 
-    public array $teamConfigArr = array();
-
     protected Db $Db;
 
     public function __construct(public Request $Request, public SessionInterface $Session, public Config $Config, public Logger $Log, public Csrf $Csrf)
     {
         $flashBag = $this->Session->getBag('flashes');
@@ -59,11 +69,11 @@ class App
 
         $this->Log->pushHandler(new ErrorLogHandler());
         $this->Users = new Users();
         $this->Db = Db::getConnection();
         // UPDATE SQL SCHEMA if necessary or show error message if version mismatch
-        $Update = new Update($this->Config, new Sql());
+        $Update = new Update((int) $this->Config->configArr['schema'], new Sql());
         $Update->checkSchema();
     }
 
     /**
      * Get the page generation time (called in the footer)
@@ -89,33 +99,43 @@ class App
     public function getMinPasswordLength(): int
     {
         return Check::MIN_PASSWORD_LENGTH;
     }
 
-    /**
-     * If the current user is authenticated, load Users with an id
-     */
-    public function loadUser(Users $users): void
+    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
+    //     _                 _      //
+    //    | |__   ___   ___ | |_    //
+    //    | '_ \ / _ \ / _ \| __|   //
+    //    | |_) | (_) | (_) | |_    //
+    //    |_.__/ \___/ \___/ \__|   //
+    //                              //
+    //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-//
+    public function boot(): void
     {
-        $this->Users = $users;
-
-        // team config
-        $Teams = new Teams($this->Users);
-        $this->teamConfigArr = $Teams->read(new ContentParams());
-    }
+        // 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'),
+            ));
+        }
 
-    /**
-     * 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);
+        // 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(),
+            ));
         }
 
-        return $lang;
+        $this->initi18n();
     }
 
     /**
      * Generate HTML from a twig template. The App object is injected into every template.
      *
@@ -125,6 +145,45 @@ class App
      */
     public function render(string $template, array $variables): string
     {
         return $this->getTwig($this->Config)->render($template, array_merge(array('App' => $this), $variables));
     }
+
+    /**
+     * 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);
+    }
 }
diff --git a/src/classes/Auth.php b/src/classes/Auth.php
index dabe38a05..944bc55e5 100644
--- a/src/classes/Auth.php
+++ b/src/classes/Auth.php
@@ -1,14 +1,13 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
 use function basename;
 use Elabftw\Exceptions\UnauthorizedException;
@@ -17,31 +16,20 @@ use Elabftw\Models\Config;
 use Elabftw\Models\Experiments;
 use Elabftw\Models\Items;
 use Elabftw\Models\Users;
 use Elabftw\Services\AnonAuth;
 use Elabftw\Services\CookieAuth;
-use Elabftw\Services\SessionAuth;
 use function in_array;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\Session\SessionInterface;
 
 /**
- * Provide methods to login a user
+ * Provide methods to authenticate a user
  */
 class Auth implements AuthInterface
 {
-    private Config $Config;
-
-    private SessionInterface $Session;
-
-    private Request $Request;
-
-    public function __construct(App $app)
+    public function __construct(private Config $Config, private Request $Request)
     {
-        $this->Config = $app->Config;
-        $this->Request = $app->Request;
-        $this->Session = $app->Session;
     }
 
     /**
      * Try to authenticate with session and cookie
      *     ____          _
@@ -55,56 +43,12 @@ class Auth implements AuthInterface
     {
         $AuthService = $this->getAuthService($this->getAuthType());
         return $AuthService->tryAuth();
     }
 
-    /**
-     * 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);
-    }
-
     private function getAuthType(): string
     {
-        // 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';
-        }
-
         // try to login with the elabid for an entity in view mode
         $page = basename($this->Request->getScriptName());
         if ($this->Request->query->has('elabid')
             && ($page === 'experiments.php' || $page === 'database.php')
             && $this->Request->query->get('mode') === 'view') {
@@ -127,12 +71,10 @@ class Auth implements AuthInterface
     {
         switch ($authType) {
             // AUTH WITH COOKIE
             case 'cookie':
                 return new CookieAuth((string) $this->Request->cookies->get('token'), $this->Request->cookies->getDigits('token_team'));
-            case 'session':
-                return new SessionAuth();
             case 'elabid':
                 // now we need to know in which team we autologin the user
                 // use the page from the request to determine if it's from items or experiments
                 $page = $this->Request->getScriptName();
                 if ($page === '/experiments.php') {
diff --git a/src/classes/Csrf.php b/src/classes/Csrf.php
index aa2abd7d8..bb73b8358 100644
--- a/src/classes/Csrf.php
+++ b/src/classes/Csrf.php
@@ -1,21 +1,18 @@
-<?php
+<?php declare(strict_types=1);
 /**
- * \Elabftw\Elabftw\Csrf.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\Elabftw;
 
 use Defuse\Crypto\Key;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
+use Elabftw\Exceptions\ImproperActionException;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Session\SessionInterface;
 
 /**
  * Prevent CSRF attacks
@@ -59,11 +56,12 @@ class Csrf
             $res = $this->validateAjax();
         } else {
             $res = $this->validateForm();
         }
         if (!$res) {
-            throw new InvalidCsrfTokenException();
+            // an invalid csrf token is most likely the result of an expired session
+            throw new ImproperActionException(_('Your session expired.'));
         }
     }
 
     /**
      * Generate a CSRF token
diff --git a/src/classes/Db.php b/src/classes/Db.php
index 88f360ccf..c0fe7858f 100644
--- a/src/classes/Db.php
+++ b/src/classes/Db.php
@@ -110,14 +110,14 @@ final class Db
     public function execute(PDOStatement $req, ?array $arr = null): bool
     {
         try {
             $res = $req->execute($arr);
         } catch (PDOException $e) {
-            throw new DatabaseErrorException('Error with SQL query', 515, $e);
+            throw new DatabaseErrorException($e);
         }
         if (!$res) {
-            throw new DatabaseErrorException('Error while executing SQL query.');
+            throw new DatabaseErrorException();
         }
         return $res;
     }
 
     /**
@@ -127,11 +127,11 @@ final class Db
      */
     public function q(string $sql): PDOStatement
     {
         $res = $this->connection->query($sql);
         if ($res === false) {
-            throw new DatabaseErrorException('Error executing query!');
+            throw new DatabaseErrorException();
         }
 
         return $res;
     }
 
diff --git a/src/classes/Update.php b/src/classes/Update.php
index ba816a66b..7a7e6506f 100644
--- a/src/classes/Update.php
+++ b/src/classes/Update.php
@@ -37,62 +37,58 @@ class Update
     /** @var int REQUIRED_SCHEMA the current version of the database structure */
     private const REQUIRED_SCHEMA = 63;
 
     private Db $Db;
 
-    public function __construct(public Config $Config, private Sql $Sql)
+    public function __construct(private int $currentSchema, private Sql $Sql)
     {
         $this->Db = Db::getConnection();
     }
 
     /**
      * Get the current required schema
      */
-    public function getRequiredSchema(): int
+    public static function getRequiredSchema(): int
     {
         return self::REQUIRED_SCHEMA;
     }
 
     /**
      * Check if the Db structure needs updating
      */
     public function checkSchema(): void
     {
-        $currentSchema = (int) $this->Config->configArr['schema'];
-
-        if ($currentSchema !== self::REQUIRED_SCHEMA) {
+        if ($this->currentSchema !== self::REQUIRED_SCHEMA) {
             throw new InvalidSchemaException();
         }
     }
 
     /**
      * Update the database schema if needed
      */
     public function runUpdateScript(): void
     {
-        $currentSchema = (int) $this->Config->configArr['schema'];
-
         // do nothing if we're up to date
-        if ($currentSchema === self::REQUIRED_SCHEMA) {
+        if ($this->currentSchema === self::REQUIRED_SCHEMA) {
             return;
         }
 
         // old style update functions have been removed, so add a block to prevent upgrade from very very old to newest directly
-        if ($currentSchema < 37) {
+        if ($this->currentSchema < 37) {
             throw new ImproperActionException('Please update first to latest version from 1.8 branch before updating to 2.0 branch! See documentation.');
         }
 
-        if ($currentSchema < 41) {
+        if ($this->currentSchema < 41) {
             throw new ImproperActionException('Please update first to latest version from 2.0 branch before updating to 3.0 branch! See documentation.');
         }
 
         // new style with SQL files instead of functions
-        while ($currentSchema < self::REQUIRED_SCHEMA) {
-            ++$currentSchema;
-            $this->Sql->execFile('schema' . (string) ($currentSchema) . '.sql');
+        while ($this->currentSchema < self::REQUIRED_SCHEMA) {
+            ++$this->currentSchema;
+            $this->Sql->execFile('schema' . (string) ($this->currentSchema) . '.sql');
             // schema57: add an elabid to existing database items
-            if ($currentSchema === 57) {
+            if ($this->currentSchema === 57) {
                 $this->addElabidToItems();
                 $this->fixExperimentsRevisions();
             }
         }
 
diff --git a/src/commands/CheckDatabase.php b/src/commands/CheckDatabase.php
index 0d1050afd..052bf5f36 100644
--- a/src/commands/CheckDatabase.php
+++ b/src/commands/CheckDatabase.php
@@ -8,11 +8,10 @@
  */
 declare(strict_types=1);
 
 namespace Elabftw\Commands;
 
-use Elabftw\Elabftw\Sql;
 use Elabftw\Elabftw\Update;
 use Elabftw\Models\Config;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -45,14 +44,13 @@ class CheckDatabase extends Command
      * @return int 0 if no need to upgrade, 1 if need to upgrade
      */
     protected function execute(InputInterface $input, OutputInterface $output): int
     {
         $Config = Config::getConfig();
-        $Update = new Update($Config, new Sql());
-
         $current = (int) $Config->configArr['schema'];
-        $required = $Update->getRequiredSchema();
+
+        $required = Update::getRequiredSchema();
 
         $output->writeln(array(
             'Database check',
             '==============',
             'Current version: ' . (string) $current,
diff --git a/src/commands/UpdateDatabase.php b/src/commands/UpdateDatabase.php
index 51b621a9e..5f5395c53 100644
--- a/src/commands/UpdateDatabase.php
+++ b/src/commands/UpdateDatabase.php
@@ -54,11 +54,11 @@ class UpdateDatabase extends Command
                 'Database update starting',
                 '========================',
             ));
 
             $Config = Config::getConfig();
-            $Update = new Update($Config, new Sql());
+            $Update = new Update((int) $Config->configArr['schema'], new Sql());
             $Update->runUpdateScript();
             $output->writeln('All done.');
         }
         return 0;
     }
diff --git a/src/controllers/LoginController.php b/src/controllers/LoginController.php
index 6919c8038..acf44724d 100644
--- a/src/controllers/LoginController.php
+++ b/src/controllers/LoginController.php
@@ -13,22 +13,28 @@ namespace Elabftw\Controllers;
 use Defuse\Crypto\Crypto;
 use Defuse\Crypto\Key;
 use Elabftw\Elabftw\App;
 use Elabftw\Elabftw\Saml;
 use Elabftw\Exceptions\ImproperActionException;
+use Elabftw\Exceptions\InvalidCredentialsException;
+use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\AuthInterface;
 use Elabftw\Interfaces\ControllerInterface;
+use Elabftw\Models\ExistingUser;
 use Elabftw\Models\Idps;
 use Elabftw\Services\AnonAuth;
+use Elabftw\Services\DeviceToken;
+use Elabftw\Services\DeviceTokenValidator;
 use Elabftw\Services\ExternalAuth;
 use Elabftw\Services\LdapAuth;
 use Elabftw\Services\LocalAuth;
 use Elabftw\Services\LoginHelper;
 use Elabftw\Services\MfaAuth;
 use Elabftw\Services\MfaHelper;
 use Elabftw\Services\SamlAuth;
 use Elabftw\Services\TeamAuth;
+use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
 use LdapRecord\Connection;
 use OneLogin\Saml2\Auth as SamlAuthLib;
 use function setcookie;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Response;
@@ -39,10 +45,12 @@ use Symfony\Component\HttpFoundation\Session\Flash\FlashBag;
  */
 class LoginController implements ControllerInterface
 {
     public function __construct(private App $App)
     {
+        $App->Csrf->validate();
+        $this->validateDeviceToken();
     }
 
     public function getResponse(): Response
     {
         // ENABLE MFA FOR OUR USER
@@ -135,19 +143,47 @@ class LoginController implements ControllerInterface
         // All good now we can login the user
         $LoginHelper = new LoginHelper($AuthResponse, $this->App->Session);
         $LoginHelper->login((bool) $icanhazcookies);
 
         // cleanup
-        $this->App->Session->remove('failed_attempt');
         $this->App->Session->remove('rememberme');
         $this->App->Session->remove('auth_userid');
 
         return new RedirectResponse(
             (string) ($this->App->Request->cookies->get('redirect') ?? '../../experiments.php')
         );
     }
 
+    /**
+     * 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
+        }
+    }
+
     private function getAuthService(string $authType): AuthInterface
     {
         switch ($authType) {
             // AUTH WITH LDAP
             case 'ldap':
diff --git a/src/exceptions/DatabaseErrorException.php b/src/exceptions/DatabaseErrorException.php
index 24778b6b2..f968a0663 100644
--- a/src/exceptions/DatabaseErrorException.php
+++ b/src/exceptions/DatabaseErrorException.php
@@ -1,30 +1,22 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @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;
 
 /**
  * Throw this if the SQL query failed
  */
-class DatabaseErrorException extends Exception
+final 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)
+    public function __construct(?Exception $previous = null)
     {
-        parent::__construct($message, $code, $previous);
+        parent::__construct('An error occured during the execution of the SQL query.', 515, $previous);
     }
 }
diff --git a/src/exceptions/FilesystemErrorException.php b/src/exceptions/FilesystemErrorException.php
index 860cf377e..421222b27 100644
--- a/src/exceptions/FilesystemErrorException.php
+++ b/src/exceptions/FilesystemErrorException.php
@@ -1,31 +1,17 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
-use Exception;
-
 /**
  * For errors related to the filesystem (read/write files)
  */
-class FilesystemErrorException extends Exception
+final class FilesystemErrorException extends WithMessageException
 {
-    /**
-     * 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);
-    }
 }
diff --git a/src/exceptions/IllegalActionException.php b/src/exceptions/IllegalActionException.php
index f866620cf..ecf947819 100644
--- a/src/exceptions/IllegalActionException.php
+++ b/src/exceptions/IllegalActionException.php
@@ -1,31 +1,17 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
-use Exception;
-
 /**
  * For errors that are suspicious (request has been edited for instance)
  */
-class IllegalActionException extends Exception
+final class IllegalActionException extends WithMessageException
 {
-    /**
-     * 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);
-    }
 }
diff --git a/src/exceptions/ImproperActionException.php b/src/exceptions/ImproperActionException.php
index 34ddb53c7..072433f2d 100644
--- a/src/exceptions/ImproperActionException.php
+++ b/src/exceptions/ImproperActionException.php
@@ -1,31 +1,17 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
-use Exception;
-
 /**
  * For errors that make the execution halt but can happen and are not malicious
  */
-class ImproperActionException extends Exception
+final class ImproperActionException extends WithMessageException
 {
-    /**
-     * 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);
-    }
 }
diff --git a/src/exceptions/InvalidCredentialsException.php b/src/exceptions/InvalidCredentialsException.php
index 71332812e..a40279091 100644
--- a/src/exceptions/InvalidCredentialsException.php
+++ b/src/exceptions/InvalidCredentialsException.php
@@ -1,27 +1,23 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
 use Exception;
 
 /**
  * Throw this if the auth is not good
  */
-class InvalidCredentialsException extends Exception
+final class InvalidCredentialsException extends Exception
 {
-    public function __construct(?string $message = null)
+    public function __construct(int $userid)
     {
-        if ($message === null) {
-            $message = _('Invalid email/password combination.');
-        }
-        parent::__construct($message, 0);
+        parent::__construct(_('Invalid email/password combination.'), $userid);
     }
 }
diff --git a/src/exceptions/InvalidCsrfTokenException.php b/src/exceptions/InvalidCsrfTokenException.php
deleted file mode 100644
index d52b15725..000000000
--- a/src/exceptions/InvalidCsrfTokenException.php
+++ /dev/null
@@ -1,32 +0,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);
-    }
-}
diff --git a/src/exceptions/InvalidDeviceTokenException.php b/src/exceptions/InvalidDeviceTokenException.php
new file mode 100644
index 000000000..a2c82322c
--- /dev/null
+++ b/src/exceptions/InvalidDeviceTokenException.php
@@ -0,0 +1,23 @@
+<?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.'));
+    }
+}
diff --git a/src/exceptions/InvalidSchemaException.php b/src/exceptions/InvalidSchemaException.php
index bf98ef299..0c6c5dd40 100644
--- a/src/exceptions/InvalidSchemaException.php
+++ b/src/exceptions/InvalidSchemaException.php
@@ -1,14 +1,13 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
 use function dirname;
 use Exception;
diff --git a/src/exceptions/ProcessFailedException.php b/src/exceptions/ProcessFailedException.php
index 540e05224..ca21afc3c 100644
--- a/src/exceptions/ProcessFailedException.php
+++ b/src/exceptions/ProcessFailedException.php
@@ -1,31 +1,17 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
-use Exception;
-
 /**
  * For errors related to the filesystem (read/write files)
  */
-class ProcessFailedException extends Exception
+final class ProcessFailedException extends WithMessageException
 {
-    /**
-     * 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);
-    }
 }
diff --git a/src/exceptions/QuantumException.php b/src/exceptions/QuantumException.php
index 753ae6810..fff590635 100644
--- a/src/exceptions/QuantumException.php
+++ b/src/exceptions/QuantumException.php
@@ -1,13 +1,12 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @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;
 
diff --git a/src/exceptions/ResourceNotFoundException.php b/src/exceptions/ResourceNotFoundException.php
index 5a97e7089..1ed912b6f 100644
--- a/src/exceptions/ResourceNotFoundException.php
+++ b/src/exceptions/ResourceNotFoundException.php
@@ -1,32 +1,24 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @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;
 
 /**
  * Throw this if the requested resource cannot be found
  * Should reply with status code 404
  */
-class ResourceNotFoundException extends Exception
+final 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)
+    public function __construct(string $message = null, int $code = 404, Exception $previous = null)
     {
         if ($message === null) {
             $message = _('Nothing to show with this id');
         }
         parent::__construct($message, $code, $previous);
diff --git a/src/exceptions/UnauthorizedException.php b/src/exceptions/UnauthorizedException.php
index f1e8f73b4..1a7b6d18f 100644
--- a/src/exceptions/UnauthorizedException.php
+++ b/src/exceptions/UnauthorizedException.php
@@ -1,32 +1,24 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Exceptions;
 
 use Exception;
 
 /**
  * If user is not authorized to access this resource
  */
-class UnauthorizedException extends Exception
+final 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)
+    public function __construct(string $message = null, int $code = 0, Exception $previous = null)
     {
         if ($message === null) {
             $message = _('Authentication required');
         }
         parent::__construct($message, $code, $previous);
diff --git a/src/exceptions/WithMessageException.php b/src/exceptions/WithMessageException.php
new file mode 100644
index 000000000..87c6c7941
--- /dev/null
+++ b/src/exceptions/WithMessageException.php
@@ -0,0 +1,23 @@
+<?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);
+    }
+}
diff --git a/src/models/AnonymousUser.php b/src/models/AnonymousUser.php
new file mode 100644
index 000000000..b1c122fcf
--- /dev/null
+++ b/src/models/AnonymousUser.php
@@ -0,0 +1,37 @@
+<?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';
+    }
+}
diff --git a/src/models/AuthFail.php b/src/models/AuthFail.php
new file mode 100644
index 000000000..5895a2492
--- /dev/null
+++ b/src/models/AuthFail.php
@@ -0,0 +1,124 @@
+<?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);
+    }
+}
diff --git a/src/models/AuthenticatedUser.php b/src/models/AuthenticatedUser.php
new file mode 100644
index 000000000..2acc2c0d0
--- /dev/null
+++ b/src/models/AuthenticatedUser.php
@@ -0,0 +1,21 @@
+<?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);
+    }
+}
diff --git a/src/models/BannedUsers.php b/src/models/BannedUsers.php
deleted file mode 100644
index ca6e90a9c..000000000
--- a/src/models/BannedUsers.php
+++ /dev/null
@@ -1,67 +0,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;
-
-/**
- * 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();
-    }
-}
diff --git a/src/models/Config.php b/src/models/Config.php
index c41594529..01620b274 100644
--- a/src/models/Config.php
+++ b/src/models/Config.php
@@ -12,11 +12,10 @@ namespace Elabftw\Models;
 
 use function array_map;
 use Defuse\Crypto\Crypto;
 use Defuse\Crypto\Key;
 use Elabftw\Elabftw\Db;
-use Elabftw\Elabftw\Sql;
 use Elabftw\Elabftw\Update;
 use Elabftw\Exceptions\DatabaseErrorException;
 use PDO;
 
 /**
@@ -87,11 +86,11 @@ final class Config
         $sql = 'SELECT * FROM config';
         $req = $this->Db->prepare($sql);
         $this->Db->execute($req);
         $config = $req->fetchAll(PDO::FETCH_COLUMN | PDO::FETCH_GROUP);
         if ($config === false) {
-            throw new DatabaseErrorException('Error while executing SQL query.');
+            throw new DatabaseErrorException();
         }
         return array_map(function ($v) {
             return $v[0];
         }, $config);
     }
@@ -153,12 +152,11 @@ final class Config
      * Insert the default values in the sql config table
      * Only run once of first ever page load
      */
     private function populate(): bool
     {
-        $Update = new Update($this, new Sql());
-        $schema = $Update->getRequiredSchema();
+        $schema = Update::getRequiredSchema();
 
         $sql = "INSERT INTO `config` (`conf_name`, `conf_value`) VALUES
             ('admin_validate', '1'),
             ('autologout_time', '0'),
             ('ban_time', '60'),
diff --git a/src/models/ExistingUser.php b/src/models/ExistingUser.php
new file mode 100644
index 000000000..19587e4cf
--- /dev/null
+++ b/src/models/ExistingUser.php
@@ -0,0 +1,49 @@
+<?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;
+    }
+}
diff --git a/src/models/Tags.php b/src/models/Tags.php
index c3bf8578e..a2d62c963 100644
--- a/src/models/Tags.php
+++ b/src/models/Tags.php
@@ -74,11 +74,11 @@ class Tags implements CrudInterface
         $insertReq2->bindParam(':item_id', $this->Entity->id, PDO::PARAM_INT);
         $insertReq2->bindParam(':item_type', $this->Entity->type);
         $insertReq2->bindParam(':tag_id', $tagId, PDO::PARAM_INT);
 
         if ($insertReq2->execute() !== true) {
-            throw new DatabaseErrorException('Error while executing SQL query.');
+            throw new DatabaseErrorException();
         }
 
         return $tagId;
     }
 
diff --git a/src/models/Uploads.php b/src/models/Uploads.php
index f68e5e411..0849107af 100644
--- a/src/models/Uploads.php
+++ b/src/models/Uploads.php
@@ -13,11 +13,10 @@ namespace Elabftw\Models;
 use function copy;
 use Elabftw\Elabftw\ContentParams;
 use Elabftw\Elabftw\Db;
 use Elabftw\Elabftw\Extensions;
 use Elabftw\Elabftw\Tools;
-use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\ContentParamsInterface;
@@ -401,22 +400,14 @@ class Uploads implements CrudInterface
         }
     }
 
     /**
      * Make the final SQL request to store the file
-     *
-     * @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
      */
     private function dbInsert(string $realName, string $longName, string $hash, ?string $comment = null): int
     {
-        if ($comment === null) {
-            $comment = 'Click to add a comment';
-        }
+        $comment ??= 'Click to add a comment';
 
         $sql = 'INSERT INTO uploads(
             real_name,
             long_name,
             comment,
diff --git a/src/models/Users.php b/src/models/Users.php
index 99ff3092b..8443bd0b9 100644
--- a/src/models/Users.php
+++ b/src/models/Users.php
@@ -11,11 +11,10 @@ declare(strict_types=1);
 namespace Elabftw\Models;
 
 use Elabftw\Elabftw\Db;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\ContentParamsInterface;
 use Elabftw\Services\Check;
 use Elabftw\Services\Email;
 use Elabftw\Services\EmailValidator;
 use Elabftw\Services\Filter;
@@ -62,22 +61,28 @@ class Users
         $this->userData = $this->read($userid);
         $this->userData['team'] = $this->team;
     }
 
     /**
-     * Create a new user. If no password is provided, it's because we create it from SAML.
+     * Create a new user
      */
-    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
-    {
+    public function create(
+        string $email,
+        array $teams,
+        string $firstname = '',
+        string $lastname = '',
+        string $password = '',
+        ?int $group = null,
+        bool $forceValidation = false,
+        bool $alertAdmin = true,
+    ): int {
         $Config = Config::getConfig();
         $Teams = new Teams($this);
 
         // make sure that all the teams in which the user will be are created/exist
         // this might throw an exception if the team doesn't exist and we can't create it on the fly
-        if ($normalizeTeams) {
-            $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
-        }
+        $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teams);
 
         $EmailValidator = new EmailValidator($email, $Config->configArr['email_domain']);
         $EmailValidator->validate();
 
         if ($password !== '') {
@@ -185,28 +190,10 @@ class Users
             $res[] = $user['userid'] . ' - ' . $user['fullname'];
         }
         return $res;
     }
 
-    /**
-     * 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);
-    }
-
     /**
      * Search users based on query. It searches in email, firstname, lastname or team name
      *
      * @param string $query the searched term
      * @param bool $teamFilter toggle between sysadmin/admin view
@@ -290,10 +277,18 @@ class Users
             return array();
         }
         return $res;
     }
 
+    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();
+    }
+
     /**
      * Update user from the editusers template
      *
      * @param array<string, mixed> $params POST
      */
diff --git a/src/models/ValidatedUser.php b/src/models/ValidatedUser.php
new file mode 100644
index 000000000..41818e48d
--- /dev/null
+++ b/src/models/ValidatedUser.php
@@ -0,0 +1,28 @@
+<?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);
+    }
+}
diff --git a/src/services/DeviceToken.php b/src/services/DeviceToken.php
new file mode 100644
index 000000000..d56badabd
--- /dev/null
+++ b/src/services/DeviceToken.php
@@ -0,0 +1,62 @@
+<?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;
+    }
+}
diff --git a/src/services/DeviceTokenValidator.php b/src/services/DeviceTokenValidator.php
new file mode 100644
index 000000000..501c96556
--- /dev/null
+++ b/src/services/DeviceTokenValidator.php
@@ -0,0 +1,48 @@
+<?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();
+        }
+    }
+}
diff --git a/src/services/ExternalAuth.php b/src/services/ExternalAuth.php
index 22742c76b..ade3c65ef 100644
--- a/src/services/ExternalAuth.php
+++ b/src/services/ExternalAuth.php
@@ -12,11 +12,12 @@ namespace Elabftw\Services;
 
 use Elabftw\Elabftw\AuthResponse;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\AuthInterface;
-use Elabftw\Models\Users;
+use Elabftw\Models\ExistingUser;
+use Elabftw\Models\ValidatedUser;
 use Monolog\Logger;
 
 /**
  * Authenticate with server provided values
  */
@@ -46,22 +47,20 @@ class ExternalAuth implements AuthInterface
             }
             $teams = array((string) $defaultTeam);
         }
 
         // get userid
-        $Users = new Users();
         try {
-            $Users->populateFromEmail($email);
+            $Users = ExistingUser::fromEmail($email);
         } catch (ResourceNotFoundException) {
             // the user doesn't exist yet in the db
             // what do we do? Lookup the config setting for that case
             if ($this->configArr['saml_user_default'] === '0') {
                 throw new ImproperActionException('Could not find an existing user. Ask a Sysadmin to create your account.');
             }
             // CREATE USER (and force validation of user)
-            $Users->create($email, $teams, $firstname, $lastname, '', null, true);
-            $Users->populateFromEmail($email);
+            $Users = ValidatedUser::fromExternal($email, $teams, $firstname, $lastname);
             $this->log->info('New user (' . $email . ') autocreated from external auth');
         }
         $this->AuthResponse->userid = (int) $Users->userData['userid'];
         $this->AuthResponse->setTeams();
 
diff --git a/src/services/ImportCsv.php b/src/services/ImportCsv.php
index d8970cf57..2b2f74226 100644
--- a/src/services/ImportCsv.php
+++ b/src/services/ImportCsv.php
@@ -82,11 +82,11 @@ class ImportCsv extends AbstractImport
             $req->bindParam(':userid', $this->Users->userData['userid']);
             $req->bindParam(':category', $this->target);
             $req->bindParam(':canread', $this->canread);
             $req->bindParam(':elabid', $elabid);
             if ($req->execute() === false) {
-                throw new DatabaseErrorException('Error inserting data in database!');
+                throw new DatabaseErrorException();
             }
             $this->inserted++;
         }
     }
 
diff --git a/src/services/LdapAuth.php b/src/services/LdapAuth.php
index c651d1064..ea652d25f 100644
--- a/src/services/LdapAuth.php
+++ b/src/services/LdapAuth.php
@@ -13,12 +13,14 @@ namespace Elabftw\Services;
 use Elabftw\Elabftw\AuthResponse;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\InvalidCredentialsException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\AuthInterface;
+use Elabftw\Models\ExistingUser;
 use Elabftw\Models\Teams;
 use Elabftw\Models\Users;
+use Elabftw\Models\ValidatedUser;
 use function is_array;
 use LdapRecord\Connection;
 use LdapRecord\Query\ObjectNotFoundException;
 
 /**
@@ -40,24 +42,23 @@ class LdapAuth implements AuthInterface
     {
         $query = $this->connection->query()->setDn($this->configArr['ldap_base_dn']);
         try {
             $record = $query->findbyOrFail('mail', $this->email);
         } catch (ObjectNotFoundException $e) {
-            throw new InvalidCredentialsException();
+            throw new InvalidCredentialsException(0);
         }
         $dn = $record['distinguishedname'] ?? $record['dn'];
         // sometimes it might be an array, make sure we give a string to auth
         if (is_array($dn)) {
             $dn = $dn[0];
         }
         if (!$this->connection->auth()->attempt($dn, $this->password)) {
-            throw new InvalidCredentialsException();
+            throw new InvalidCredentialsException(0);
         }
-        $Users = new Users();
-        $Teams = new Teams($Users);
+        $Teams = new Teams(new Users());
         try {
-            $Users->populateFromEmail($this->email);
+            $Users = ExistingUser::fromEmail($this->email);
         } catch (ResourceNotFoundException) {
             // the user doesn't exist yet in the db
             // what do we do? Lookup the config setting for that case
             if ($this->configArr['saml_user_default'] === '0') {
                 throw new ImproperActionException('Could not find an existing user. Ask a Sysadmin to create your account.');
@@ -79,11 +80,11 @@ class LdapAuth implements AuthInterface
                 $teamFromLdap = array($teamFromLdap);
             }
             // normalize the team(s)
             $teams = $Teams->getTeamsFromIdOrNameOrOrgidArray($teamFromLdap)[0];
             // CREATE USER (and force validation of user)
-            $Users = new Users($Users->create($this->email, $teams, $firstname, $lastname, '', null, true));
+            $Users = ValidatedUser::fromExternal($this->email, $teams, $firstname, $lastname);
         }
 
         $this->AuthResponse->userid = (int) $Users->userData['userid'];
         $this->AuthResponse->mfaSecret = $Users->userData['mfa_secret'];
         $this->AuthResponse->setTeams();
diff --git a/src/services/LocalAuth.php b/src/services/LocalAuth.php
index 61b173e63..0ae8202d1 100644
--- a/src/services/LocalAuth.php
+++ b/src/services/LocalAuth.php
@@ -10,13 +10,16 @@ declare(strict_types=1);
 
 namespace Elabftw\Services;
 
 use Elabftw\Elabftw\AuthResponse;
 use Elabftw\Elabftw\Db;
+use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\InvalidCredentialsException;
+use Elabftw\Exceptions\QuantumException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Interfaces\AuthInterface;
+use Elabftw\Models\ExistingUser;
 use Elabftw\Models\Users;
 use function password_hash;
 use function password_needs_rehash;
 use function password_verify;
 use PDO;
@@ -63,23 +66,22 @@ class LocalAuth implements AuthInterface
         $req = $this->Db->prepare($sql);
         $req->bindParam(':email', $this->email);
         $this->Db->execute($req);
         $res = $req->fetchColumn();
         if ($res === false || $res === null) {
-            throw new InvalidCredentialsException();
+            throw new ImproperActionException('Could not find salt!');
         }
         return (string) $res;
     }
 
     private function getUseridFromEmail(): int
     {
-        $Users = new Users();
         try {
-            $Users->populateFromEmail($this->email);
-            // if the email is not found, transform the exception in the general invalidcredentials error
-        } catch (ResourceNotFoundException) {
-            throw new InvalidCredentialsException();
+            $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.'));
         }
         return (int) $Users->userData['userid'];
     }
 
     /**
@@ -126,11 +128,11 @@ class LocalAuth implements AuthInterface
         $req->bindParam(':email', $this->email);
         $req->bindParam(':passwordHash', $passwordHash);
         $this->Db->execute($req);
 
         if ($req->rowCount() !== 1) {
-            throw new InvalidCredentialsException();
+            throw new InvalidCredentialsException($this->userid);
         }
     }
 
     private function authWithModernAlgo(): void
     {
@@ -139,11 +141,11 @@ class LocalAuth implements AuthInterface
         $req->bindParam(':userid', $this->userid, PDO::PARAM_INT);
         $this->Db->execute($req);
         $res = $req->fetch();
         // verify password
         if (password_verify($this->password, $res['password_hash']) !== true) {
-            throw new InvalidCredentialsException();
+            throw new InvalidCredentialsException($this->userid);
         }
         // check if it needs rehash (new algo)
         if (password_needs_rehash($res['password_hash'], PASSWORD_DEFAULT)) {
             $passwordHash = password_hash($this->password, PASSWORD_DEFAULT);
             $sql = 'UPDATE users SET password_hash = :password_hash WHERE userid = :userid';
diff --git a/src/services/LoginHelper.php b/src/services/LoginHelper.php
index f039b274c..d01d5eb45 100644
--- a/src/services/LoginHelper.php
+++ b/src/services/LoginHelper.php
@@ -34,19 +34,16 @@ class LoginHelper
      * Login means having some anon / auth in session + team + userid
      * and set the cookie "token" if it was requested
      */
     public function login(bool $setCookie): void
     {
-        // no need to login again if the session is valid
-        if ($this->AuthResponse->isAuthBy === 'session') {
-            return;
-        }
         $this->populateSession();
         if ($setCookie) {
             $this->setToken();
         }
         $this->updateLastLogin();
+        $this->setDeviceToken();
     }
 
     /**
      * Update last login time of user
      */
@@ -56,10 +53,25 @@ class LoginHelper
         $req = $this->Db->prepare($sql);
         $req->bindParam(':userid', $this->AuthResponse->userid, PDO::PARAM_INT);
         $this->Db->execute($req);
     }
 
+    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);
+    }
+
     /**
      * Store userid and permissions in session
      */
     private function populateSession(): void
     {
diff --git a/src/services/MfaAuth.php b/src/services/MfaAuth.php
index e36ce3252..bfbb92314 100644
--- a/src/services/MfaAuth.php
+++ b/src/services/MfaAuth.php
@@ -30,11 +30,11 @@ class MfaAuth implements AuthInterface
     public function tryAuth(): AuthResponse
     {
         $Users = new Users($this->MfaHelper->userid);
 
         if (!$this->MfaHelper->verifyCode($this->code)) {
-            throw new InvalidCredentialsException('The code you entered is not valid!');
+            throw new InvalidCredentialsException($this->MfaHelper->userid);
         }
 
         $this->AuthResponse->hasVerifiedMfa = true;
         $this->AuthResponse->mfaSecret = $Users->userData['mfa_secret'];
         $this->AuthResponse->userid = $this->MfaHelper->userid;
diff --git a/src/services/Populate.php b/src/services/Populate.php
index f7217bcf7..a970df4c0 100644
--- a/src/services/Populate.php
+++ b/src/services/Populate.php
@@ -112,11 +112,11 @@ class Populate
         $firstname = $user['firstname'] ?? $this->faker->firstName();
         $lastname = $user['lastname'] ?? $this->faker->lastName();
         $password = $user['password'] ?? self::DEFAULT_PASSWORD;
         $email = $user['email'] ?? $this->faker->safeEmail();
 
-        $userid = $Teams->Users->create($email, array($user['team']), $firstname, $lastname, $password, null, true, true, false);
+        $userid = $Teams->Users->create($email, array($user['team']), $firstname, $lastname, $password, null, true, false);
         $team = $Teams->getTeamsFromIdOrNameOrOrgidArray(array($user['team']));
         $Users = new Users($userid, (int) $team[0]['id']);
 
         if ($user['create_mfa_secret'] ?? false) {
             $MfaHelper = new MfaHelper($userid);
diff --git a/src/services/SamlAuth.php b/src/services/SamlAuth.php
index 357019ac1..c8d0787fb 100644
--- a/src/services/SamlAuth.php
+++ b/src/services/SamlAuth.php
@@ -16,12 +16,14 @@ use Elabftw\Elabftw\Tools;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Interfaces\AuthInterface;
 use Elabftw\Models\Config;
+use Elabftw\Models\ExistingUser;
 use Elabftw\Models\Teams;
 use Elabftw\Models\Users;
+use Elabftw\Models\ValidatedUser;
 use function is_array;
 use OneLogin\Saml2\Auth as SamlAuthLib;
 
 /**
  * SAML auth service
@@ -149,14 +151,12 @@ class SamlAuth implements AuthInterface
         throw new ImproperActionException('Could not find team ID to assign user!');
     }
 
     private function getUsers(string $email, array $samlUserdata): Users
     {
-        $Users = new Users();
-        // user might not exist yet and populateFromEmail() will throw a ResourceNotFoundException
         try {
-            $Users->populateFromEmail($email);
+            $Users = ExistingUser::fromEmail($email);
         } catch (ResourceNotFoundException) {
             // the user doesn't exist yet in the db
             // what do we do? Lookup the config setting for that case
             if ($this->configArr['saml_user_default'] === '0') {
                 throw new ImproperActionException('Could not find an existing user. Ask a Sysadmin to create your account.');
@@ -178,10 +178,10 @@ class SamlAuth implements AuthInterface
             if (!isset($teams[0]['id'])) {
                 $teams = $teams[0];
             }
 
             // CREATE USER (and force validation of user, with user permissions)
-            $Users = new Users($Users->create($email, $teams, $firstname, $lastname, '', 4, true, false, false));
+            $Users = ValidatedUser::fromExternal($email, $teams, $firstname, $lastname);
         }
         return $Users;
     }
 }
diff --git a/src/services/SessionAuth.php b/src/services/SessionAuth.php
deleted file mode 100644
index 46a58222e..000000000
--- a/src/services/SessionAuth.php
+++ /dev/null
@@ -1,35 +0,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\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;
-    }
-}
diff --git a/src/sql/schema63.sql b/src/sql/schema63.sql
index d199b88dd..852754004 100644
--- a/src/sql/schema63.sql
+++ b/src/sql/schema63.sql
@@ -1,6 +1,12 @@
 -- Schema 63
 START TRANSACTION;
+    -- 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`));
     ALTER TABLE `teams` ADD `force_exp_tpl` TINYINT(1) UNSIGNED NOT NULL DEFAULT '0';
     ALTER TABLE `items_types` CHANGE `name` `name` VARCHAR(255) NOT NULL;
+    DROP TABLE `banned_users`;
     UPDATE `config` SET `conf_value` = 63 WHERE `conf_name` = 'schema';
 COMMIT;
diff --git a/src/sql/structure.sql b/src/sql/structure.sql
index 9b5a350bd..34e9df565 100644
--- a/src/sql/structure.sql
+++ b/src/sql/structure.sql
@@ -37,32 +37,27 @@ CREATE TABLE `api_keys` (
   `team` int(10) UNSIGNED NOT NULL,
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 --
--- RELATIONSHIPS FOR TABLE `api_keys`:
---   `userid`
---       `users` -> `userid`
---   `team`
---       `teams` -> `id`
---
-
--- --------------------------------------------------------
-
---
--- Table structure for table `banned_users`
+-- Table structure for table `authfail`
 --
 
-CREATE TABLE `banned_users` (
+CREATE TABLE `authfail` (
   `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
-  `fingerprint` char(32) NOT NULL,
-  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `users_id` int(10) UNSIGNED NOT NULL,
+  `attempt_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `device_token` varchar(255) DEFAULT NULL,
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 --
--- RELATIONSHIPS FOR TABLE `banned_users`:
+-- RELATIONSHIPS FOR TABLE `api_keys`:
+--   `userid`
+--       `users` -> `userid`
+--   `team`
+--       `teams` -> `id`
 --
 
 -- --------------------------------------------------------
 
 --
@@ -411,10 +406,21 @@ CREATE TABLE `items_types` (
 --   `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;
+
 
 --
 -- Table structure for table `pin2users`
 --
 
@@ -664,10 +670,12 @@ CREATE TABLE `users` (
   `pdfa` tinyint(1) UNSIGNED NOT NULL DEFAULT '1',
   `pdf_format` varchar(255) NOT NULL DEFAULT 'A4',
   `display_size` varchar(2) NOT NULL DEFAULT 'lg',
   `display_mode` VARCHAR(2) NOT NULL DEFAULT 'it',
   `last_login` DATETIME NULL DEFAULT NULL,
+  `allow_untrusted` tinyint(1) UNSIGNED NOT NULL DEFAULT '1',
+  `auth_lock_time` datetime DEFAULT NULL,
   PRIMARY KEY (`userid`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
 --
 -- RELATIONSHIPS FOR TABLE `users`:
diff --git a/src/templates/admin.html b/src/templates/admin.html
index 2ca7bf587..feff1911a 100644
--- a/src/templates/admin.html
+++ b/src/templates/admin.html
@@ -34,95 +34,95 @@
     <form class='form-group' method='post' action='app/controllers/AdminController.php' autocomplete='off'>
       <input type='hidden' value='true' name='teamsUpdateFull' />
       {{ App.Csrf.getHiddenInput|raw }}
       <label for='deletable_xp'>{{ 'Users can delete experiments:'|trans }}</label>
       <select class='form-control col-md-4' name='deletable_xp' id='deletable_xp'>
-        <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'{{ teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.deletable_xp ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
       <span class='smallgray'>{{ 'An admin account will always be able to delete experiments.'|trans }}</span>
 
       <label for='deletable_item'>{{ 'Users can delete items:'|trans }}</label>
       <select class='form-control col-md-4' name='deletable_item' id='deletable_item'>
-        <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'{{ teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.deletable_item ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
       <span class='smallgray'>{{ 'An admin account will always be able to delete items.'|trans }}</span>
 
       <label for='user_create_tag'>{{ 'Users can create tags:'|trans }}</label>
       <select class='form-control col-md-4' name='user_create_tag' id='user_create_tag'>
-        <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'{{ teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.user_create_tag ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
       <span class='smallgray'>{{ 'An admin account will always be able to create tags.'|trans }}</span>
 
       <label for='force_exp_tpl'>{{ 'Force experiment template use:'|trans }}</label>
       <select class='form-control col-md-4' name='force_exp_tpl' id='force_exp_tpl'>
-        <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'{{ teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.force_exp_tpl ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
 
       <label for='public_db'>{{ 'Anonymous visitors can see database items:'|trans }}</label>
       <select class='form-control col-md-4' name='public_db' id='public_db'>
-        <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>
+        <option value='1'{{ teamConfigArr.public_db ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.public_db ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
 
       <label for='link_name'>{{ 'Name of the link in the top menu:'|trans }}</label>
-      <input class='form-control col-md-4' type='text' value='{{ App.teamConfigArr.link_name }}' name='link_name' id='link_name' />
+      <input class='form-control col-md-4' type='text' value='{{ teamConfigArr.link_name }}' name='link_name' id='link_name' />
 
       <label for='link_href'>{{ 'Address where this link should point:'|trans }}</label>
-      <input class='mb-4 form-control col-md-4' type='text' value='{{ App.teamConfigArr.link_href }}' name='link_href' id='link_href' />
+      <input class='mb-4 form-control col-md-4' type='text' value='{{ teamConfigArr.link_href }}' name='link_href' id='link_href' />
 
       <label for='do_force_canread'>{{ 'Enforce read permissions?'|trans }}</label>
       <select class='form-control col-md-4' name='do_force_canread' id='do_force_canread'>
-        <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='1'{{ teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.do_force_canread ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
       <span class='smallgray'>{{ 'Select if you want to enforce read permissions on experiments'|trans }}</span>
 
       <label for='force_canread'>{{ 'Enforced read permissions on experiments'|trans }}</label>
       <select class='form-control col-md-4' name='force_canread' id='force_canread'>
-        <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='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>
       </select>
       <span class='smallgray'>{{ 'Enforce the read permission for all experiments of users in this team.'|trans }}</span>
 
       <label for='do_force_canwrite'>{{ 'Enforce write permissions?'|trans }}</label>
       <select class='form-control col-md-4' name='do_force_canwrite' id='do_force_canwrite'>
-        <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='1'{{ teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'Yes'|trans }}</option>
+        <option value='0'{{ not teamConfigArr.do_force_canwrite ? " selected='selected'" }}>{{ 'No'|trans }}</option>
       </select>
       <span class='smallgray'>{{ 'Select if you want to enforce write permissions on experiments'|trans }}</span>
 
       <label for='force_canwrite'>{{ 'Enforced write permissions on experiments'|trans }}</label>
       <select class='form-control col-md-4' name='force_canwrite' id='force_canwrite'>
-        <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>
+        <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>
       </select>
       <span class='smallgray'>{{ 'Enforce the write permission for all experiments of users in this team.'|trans }}</span>
 
       <span data-action='toggle-next' class='m-0 button btn btn-neutral'>{{ 'Override general timestamping config'|trans }}</span>
       <div hidden class='mt-2'>
         <label for='stampprovider'>{{ 'URL for external timestamping service:'|trans }}</label>
-        <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='url' placeholder='http://zeitstempel.dfn.de/' value='{{ teamConfigArr.stampprovider }}' name='stampprovider' id='stampprovider' />
         <span class='smallgray'>{{ 'This should be the URL used for %sRFC 3161%s-compliant timestamping requests.'|trans|format("<a href='https://tools.ietf.org/html/rfc3161'>", "</a>")|raw }}</span>
 
         <!-- TODO provide an upload button -->
         <label for='stampcert'>{{ 'Chain of certificates of the external timestamping service:'|trans }}</label>
-        <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' type='text' placeholder='app/dfn-cert/pki.dfn.pem' value='{{ teamConfigArr.stampcert }}' name='stampcert' id='stampcert' />
         <span class='smallgray'>{{ "This should point to the chain of certificates used by your external timestamping provider to sign the timestamps.%sLocal path relative to eLabFTW installation directory. You should add it in the 'uploads' folder. The file needs to be in %sPEM-encoded (ASCII)%s format!"|trans|format("<br>", "<a href='https://en.wikipedia.org/wiki/Privacy-enhanced_Electronic_Mail'>", "</a>")|raw }}</span>
         <label for='stamplogin'>{{ 'Login for external timestamping service:'|trans }}</label>
-        <input class='form-control col-md-4' autocomplete='off' type='text' value='{{ App.teamConfigArr.stamplogin }}' name='stamplogin' id='stamplogin' />
+        <input class='form-control col-md-4' autocomplete='off' type='text' value='{{ teamConfigArr.stamplogin }}' name='stamplogin' id='stamplogin' />
         <span class='smallgray'>{{ 'This should be the login associated with your timestamping service provider'|trans }}</span>
 
         <label for='stamppass'>{{ 'Password for external timestamping service:'|trans }}</label>
-        {% if App.teamConfigArr.stamppass|length > 1 %}
+        {% if teamConfigArr.stamppass|length > 1 %}
         <p>{{ 'A password is already set.'|trans }}
         {{ '%sClear it%s or change it below:'|trans|format("
         <a href='app/controllers/AdminController.php?clearStamppass=1'>",
         "</a>"
         )|raw }}
@@ -133,11 +133,11 @@
       </div>
 
       <br>
       <label for="common_template">{{ 'Common Experiment Template'|trans }}</label>
       <textarea style='height:400px' class='mceditable' name="common_template" id='common_template'>
-          {{ App.teamConfigArr.common_template|raw }}
+          {{ teamConfigArr.common_template|raw }}
       </textarea>
       <span class='smallgray'>{{ 'This is the default text when someone creates an experiment.'|trans }}</span>
 
       <div class='mt-4 text-center'>
         <button type='submit' class='button btn btn-primary'>Save</button>
diff --git a/src/templates/failed-attempt.html b/src/templates/failed-attempt.html
deleted file mode 100644
index 54921a7c5..000000000
--- a/src/templates/failed-attempt.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<!-- 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 %}
diff --git a/src/templates/head.html b/src/templates/head.html
index 839812d52..368390098 100644
--- a/src/templates/head.html
+++ b/src/templates/head.html
@@ -4,11 +4,11 @@
  * @copyright 2012 Nicolas CARPi
  * @see https://www.elabftw.net Official website
  * @license AGPL-3.0
  * @package elabftw
 -->
-<html lang='{{ App.getLangForHtmlAttribute }}'>
+<html lang='{{ App.getLang() | slice(0, 2) }}'>
 
 <head>
   <meta http-equiv='Content-type' content='text/html;charset=UTF-8' />
   <meta name='viewport' content='width=device-width, initial-scale=1'>
   <meta name='description' content='Electronic lab notebook - eLabFTW'>
@@ -73,11 +73,11 @@
                 href='team.php'>{{ 'Team'|trans }}</a>
             {% endif %}
 
             <a class='nav-item nav-link {{ App.Request.getScriptName|split('/')|last == 'search.php' ? 'active' }}'
                 href='search.php'>{{ 'Search'|trans }}</a>
-            <a class='nav-item nav-link' href='{{ App.teamConfigArr.link_href }}' target='_blank' rel='noopener'>{{ App.teamConfigArr.link_name }}</a>
+            <a class='nav-item nav-link' href='{{ App.linkHref }}' target='_blank' rel='noopener'>{{ App.linkName }}</a>
 
         </div>
 
         <!-- SEARCH BAR -->
         <form class='nav-item form-inline ml-auto' id='big_search' method='get' action='{{ App.Request.getScriptName }}'>
diff --git a/src/templates/login.html b/src/templates/login.html
index bd5e95238..a1315da22 100644
--- a/src/templates/login.html
+++ b/src/templates/login.html
@@ -1,9 +1,8 @@
 {% extends 'base.html' %}
 
 {% block body %}
-{{ include('failed-attempt.html') }}
 
 <!-- Modal for reset password -->
 <div class='modal fade' id='resetModal' tabindex='-1' role='dialog' aria-labelledby='resetModalLabel' aria-hidden='true'>
   <div class='modal-dialog modal-dialog-centered' role='document'>
     <div class='modal-content'>
diff --git a/src/templates/sysconfig.html b/src/templates/sysconfig.html
index 022e39d64..604a19ef1 100644
--- a/src/templates/sysconfig.html
+++ b/src/templates/sysconfig.html
@@ -327,13 +327,23 @@ MySQL database structure version: {{ App.Config.configArr.schema }}</p>
       <button type='submit' class='button btn btn-primary'>{{ 'Save'|trans }}</button>
     </div>
   </form>
 
   <hr>
-  <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>
+  <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>
 </div>
 
 
 <!-- TAB 6 EMAIL -->
 <div class='divhandle' id='tab6div'>
diff --git a/src/tools/phpstan.neon b/src/tools/phpstan.neon
index 08e7f1cad..0c627c095 100644
--- a/src/tools/phpstan.neon
+++ b/src/tools/phpstan.neon
@@ -8,13 +8,10 @@ parameters:
       message: '#Variable \$App might not be defined.*#'
       path: %currentWorkingDirectory%/web
     -
       message: '#Variable \$Request might not be defined.*#'
       path: %currentWorkingDirectory%/web
-    -
-      message: '#Variable \$Auth might not be defined.*#'
-      path: %currentWorkingDirectory%/web
   excludePaths:
     - %currentWorkingDirectory%/src/langs/js-strings.php
     - %currentWorkingDirectory%/src/tools/rector.php
 
   paths:
diff --git a/src/ts/sysconfig.ts b/src/ts/sysconfig.ts
index 3f4cce8e1..94e495b28 100644
--- a/src/ts/sysconfig.ts
+++ b/src/ts/sysconfig.ts
@@ -6,11 +6,10 @@
  * @package elabftw
  */
 import { notif } from './misc';
 import i18next from 'i18next';
 import tinymce from 'tinymce/tinymce';
-import { Ajax } from './Ajax.class';
 import { getTinymceBaseConfig } from './tinymce';
 
 $(document).ready(function() {
   if (window.location.pathname !== '/sysconfig.php') {
     return;
@@ -156,23 +155,28 @@ $(document).ready(function() {
   });
 
   // Add click listener and do action based on which element is clicked
   document.querySelector('.real-container').addEventListener('click', (event) => {
     const el = (event.target as HTMLElement);
-    // 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);
-      });
+    // 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);
+        });
     }
   });
 
-
   // MASS MAIL
   $(document).on('click', '#massSend', function() {
     $('#massSend').prop('disabled', true);
     $('#massSend').text('Sending…');
     $.post('app/controllers/SysconfigAjaxController.php', {
diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml
index 050415544..b0b480043 100644
--- a/tests/docker-compose.yml
+++ b/tests/docker-compose.yml
@@ -4,11 +4,11 @@
 version: '3'
 
 services:
 # our first container is nginx + php-fpm + elabftw
     web:
-        image: elabftw/elabimg:dev
+        image: elabftw/elabimg:hypernext
         container_name: elabtmp
         # drop some capabilities
         cap_drop:
             - SYS_ADMIN
             - AUDIT_WRITE
@@ -37,10 +37,11 @@ services:
             - MAX_UPLOAD_SIZE=200M
             - PHP_TIMEZONE=Europe/Paris
             - TZ=Europe/Paris
             - USE_REDIS=false
             - ENABLE_IPV6=true
+            - DEV_MODE=true
         volumes:
             - ..:/elabftw
             - ../_coverage:/elabftw/tests/_output/coverage
         networks:
             - elabftw-net
diff --git a/tests/unit/classes/AuthTest.php b/tests/unit/classes/AuthTest.php
index 9242f5b18..6842f04ab 100644
--- a/tests/unit/classes/AuthTest.php
+++ b/tests/unit/classes/AuthTest.php
@@ -7,40 +7,24 @@
  * @package elabftw
  */
 
 namespace Elabftw\Elabftw;
 
-use Elabftw\Exceptions\InvalidCredentialsException;
+use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\Config;
-use Monolog\Logger;
 use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\HttpFoundation\Session\Session;
 
 class AuthTest extends \PHPUnit\Framework\TestCase
 {
     private Auth $Auth;
 
     protected function setUp(): void
     {
-        $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);
+        $this->Auth = new Auth(Config::getConfig(), Request::createFromGlobals());
     }
 
-    /*
-    public function testCheckCredentials()
+    public function testTryAuth(): void
     {
-        $this->assertEquals($this->Auth->checkCredentials('phpunit@example.com', 'phpunitftw'), 1);
-        $this->expectException(InvalidCredentialsException::class);
-        $this->Auth->checkCredentials('phpunit@example.com', 'wrong password');
+        $this->expectException(UnauthorizedException::class);
+        $this->Auth->tryAuth();
     }
-     */
-
-    /*
-    public function testLogin()
-    {
-        $this->assertTrue($this->Auth->login($this->Auth->checkCredentials('phpunit@example.com', 'phpunitftw')));
-        //$this->assertFalse($this->Auth->login('toto@yopmail.com', '0'));
-    }
-     */
 }
diff --git a/tests/unit/models/AuthFailTest.php b/tests/unit/models/AuthFailTest.php
new file mode 100644
index 000000000..726f09f8c
--- /dev/null
+++ b/tests/unit/models/AuthFailTest.php
@@ -0,0 +1,43 @@
+<?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();
+    }
+}
diff --git a/tests/unit/models/BannedUsersTest.php b/tests/unit/models/BannedUsersTest.php
deleted file mode 100644
index ac18cbc02..000000000
--- a/tests/unit/models/BannedUsersTest.php
+++ /dev/null
@@ -1,31 +0,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()));
-    }
-}
diff --git a/tests/unit/models/UsersTest.php b/tests/unit/models/UsersTest.php
index 73b85d423..4f3608d28 100644
--- a/tests/unit/models/UsersTest.php
+++ b/tests/unit/models/UsersTest.php
@@ -1,6 +1,13 @@
 <?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\Maps\UserPreferences;
 
diff --git a/tests/unit/services/DeviceTokenTest.php b/tests/unit/services/DeviceTokenTest.php
new file mode 100644
index 000000000..5799d7722
--- /dev/null
+++ b/tests/unit/services/DeviceTokenTest.php
@@ -0,0 +1,18 @@
+<?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));
+    }
+}
diff --git a/tests/unit/services/DeviceTokenValidatorTest.php b/tests/unit/services/DeviceTokenValidatorTest.php
new file mode 100644
index 000000000..638f33c50
--- /dev/null
+++ b/tests/unit/services/DeviceTokenValidatorTest.php
@@ -0,0 +1,44 @@
+<?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();
+    }
+}
diff --git a/tests/unit/services/LocalAuthTest.php b/tests/unit/services/LocalAuthTest.php
index 897c6adb5..dbe58fdef 100644
--- a/tests/unit/services/LocalAuthTest.php
+++ b/tests/unit/services/LocalAuthTest.php
@@ -9,10 +9,11 @@
 declare(strict_types=1);
 
 namespace Elabftw\Services;
 
 use Elabftw\Exceptions\InvalidCredentialsException;
+use Elabftw\Exceptions\QuantumException;
 
 class LocalAuthTest extends \PHPUnit\Framework\TestCase
 {
     private LocalAuth $AuthService;
 
@@ -29,11 +30,11 @@ class LocalAuthTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(1, $authResponse->selectedTeam);
     }
 
     public function testTryAuthWithInvalidEmail(): void
     {
-        $this->expectException(InvalidCredentialsException::class);
+        $this->expectException(QuantumException::class);
         $AuthService = new LocalAuth('invalid@example.com', 'nopenope');
     }
 
     public function testTryAuthWithInvalidPassword(): void
     {
diff --git a/tests/unit/services/SessionAuthTest.php b/tests/unit/services/SessionAuthTest.php
deleted file mode 100644
index 670343a21..000000000
--- a/tests/unit/services/SessionAuthTest.php
+++ /dev/null
@@ -1,23 +0,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\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);
-    }
-}
diff --git a/web/admin.php b/web/admin.php
index e92b9a61c..a7bc8c61d 100644
--- a/web/admin.php
+++ b/web/admin.php
@@ -47,10 +47,11 @@ try {
     $TeamGroups = new TeamGroups($App->Users);
     $Teams = new Teams($App->Users);
 
     $itemsTypesArr = $ItemsTypes->readAll();
     $statusArr = $Status->read(new ContentParams());
+    $teamConfigArr = $Teams->read(new ContentParams());
     $teamGroupsArr = $TeamGroups->read(new ContentParams());
     $teamsArr = $Teams->readAll();
     $allTeamUsersArr = $App->Users->readAllFromTeam();
     // only the unvalidated ones
     $unvalidatedUsersArr = array_filter($allTeamUsersArr, function ($u) {
@@ -78,10 +79,11 @@ try {
         'tagsArr' => $tagsArr,
         'fromSysconfig' => false,
         'isSearching' => $isSearching,
         'itemsTypesArr' => $itemsTypesArr,
         'statusArr' => $statusArr,
+        'teamConfigArr' => $teamConfigArr,
         'teamGroupsArr' => $teamGroupsArr,
         'visibilityArr' => $TeamGroups->getVisibilityList(),
         'teamsArr' => $teamsArr,
         'unvalidatedUsersArr' => $unvalidatedUsersArr,
         'usersArr' => $usersArr,
diff --git a/web/app/controllers/AdminController.php b/web/app/controllers/AdminController.php
index 1ef7d9dc3..18380864e 100644
--- a/web/app/controllers/AdminController.php
+++ b/web/app/controllers/AdminController.php
@@ -12,11 +12,10 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Maps\Team;
 use Elabftw\Models\Teams;
 use Exception;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -50,11 +49,11 @@ try {
         $Teams->destroyStamppass();
     }
 
     // DISPLAY RESULT
     $App->Session->getFlashBag()->add('ok', _('Saved'));
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e)));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/ApiController.php b/web/app/controllers/ApiController.php
index e2c2eecc2..3625db36c 100644
--- a/web/app/controllers/ApiController.php
+++ b/web/app/controllers/ApiController.php
@@ -15,40 +15,21 @@ use Elabftw\Controllers\ApiController;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\UnauthorizedException;
-use Elabftw\Models\Config;
 use Exception;
-use Monolog\Logger;
-use PDOException;
-use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\HttpFoundation\Session\Session;
 
 /**
  * This file is called without any auth, so we don't load init.inc.php but only what we need
  */
-require_once dirname(__DIR__, 3) . '/config.php';
-require_once dirname(__DIR__, 3) . '/vendor/autoload.php';
+require_once dirname(__DIR__) . '/init.inc.php';
 
-// create Request object
-$Request = Request::createFromGlobals();
-$Session = new Session();
-$Session->start();
-$Request->setSession($Session);
 $Response = new Response(Tools::error(), 500);
 
 try {
-    // 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?');
-    }
     $ApiController = new ApiController($App);
     $Response = $ApiController->getResponse();
 } catch (UnauthorizedException $e) {
     // send error 401 if it's lacking an Authorization header, with WWW-Authenticate header as per spec:
     // https://tools.ietf.org/html/rfc7235#section-3.1
diff --git a/web/app/controllers/CreateUserController.php b/web/app/controllers/CreateUserController.php
index 84f137c84..e28dc8181 100644
--- a/web/app/controllers/CreateUserController.php
+++ b/web/app/controllers/CreateUserController.php
@@ -10,11 +10,11 @@ declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
+use Elabftw\Models\ValidatedUser;
 use Elabftw\Services\Check;
 use Exception;
 use Swift_TransportException;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -45,16 +45,15 @@ try {
         !filter_var($Request->request->get('email'), FILTER_VALIDATE_EMAIL)) {
         throw new ImproperActionException(_('A mandatory field is missing!'));
     }
 
     // Create user
-    $userid = $App->Users->create(
+    $Users = ValidatedUser::fromAdmin(
         $Request->request->get('email'),
         array($Request->request->get('team')),
         $Request->request->get('firstname'),
         $Request->request->get('lastname'),
-        '',
         (int) $Request->request->get('usergroup'),
     );
 
     $App->Session->getFlashBag()->add('ok', _('Account successfully created'));
 
@@ -63,11 +62,11 @@ try {
 } catch (Swift_TransportException $e) {
     // for swift error, don't display error to user as it might contain sensitive information
     // but log it and display general error. See #841
     $App->Log->error('', array('exception' => $e));
     $App->Session->getFlashBag()->add('ko', Tools::error());
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e->getMessage())));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/EntityAjaxController.php b/web/app/controllers/EntityAjaxController.php
index ec6418040..273a2744f 100644
--- a/web/app/controllers/EntityAjaxController.php
+++ b/web/app/controllers/EntityAjaxController.php
@@ -11,11 +11,10 @@ declare(strict_types=1);
 namespace Elabftw\Elabftw;
 
 use function dirname;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\Experiments;
 use Elabftw\Models\Items;
 use Elabftw\Models\ItemsTypes;
 use Elabftw\Models\Status;
@@ -197,11 +196,11 @@ try {
             'res' => true,
             'msg' => _('Saved'),
             'color' => $Category->readColor((int) $Request->request->get('categoryId')),
         ));
     }
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException | PDOException $e) {
+} catch (ImproperActionException | UnauthorizedException | PDOException $e) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),
     ));
 } catch (IllegalActionException $e) {
diff --git a/web/app/controllers/IdpsController.php b/web/app/controllers/IdpsController.php
index 0c1e5b84e..a92a4204e 100644
--- a/web/app/controllers/IdpsController.php
+++ b/web/app/controllers/IdpsController.php
@@ -1,9 +1,7 @@
 <?php
 /**
- * app/controllers/IdpsController.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
diff --git a/web/app/controllers/ImportController.php b/web/app/controllers/ImportController.php
index 81b298fda..20c932534 100644
--- a/web/app/controllers/ImportController.php
+++ b/web/app/controllers/ImportController.php
@@ -12,11 +12,10 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Services\ImportCsv;
 use Elabftw\Services\ImportZip;
 use Exception;
 use League\Csv\SyntaxError;
 use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -49,11 +48,11 @@ try {
     $Import->import();
 
     $msg = $Import->inserted . ' ' .
         ngettext('item imported successfully.', 'items imported successfully.', $Import->inserted);
     $App->Session->getFlashBag()->add('ok', $msg);
-} catch (ImproperActionException | InvalidCsrfTokenException | SyntaxError $e) {
+} catch (ImproperActionException | SyntaxError $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e)));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/LoginController.php b/web/app/controllers/LoginController.php
index 393100b2b..ce5b01d40 100644
--- a/web/app/controllers/LoginController.php
+++ b/web/app/controllers/LoginController.php
@@ -1,25 +1,25 @@
-<?php
+<?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
  */
-declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
 use function dirname;
 use Elabftw\Controllers\LoginController;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\InvalidCredentialsException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
+use Elabftw\Exceptions\InvalidDeviceTokenException;
+use Elabftw\Models\AuthFail;
 use Exception;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
 // This page is all about getting authenticated and then logged in
 require_once dirname(__DIR__) . '/init.inc.php';
@@ -27,17 +27,18 @@ require_once dirname(__DIR__) . '/init.inc.php';
 // default location for redirect
 $location = '../../login.php';
 $Response = new RedirectResponse($location);
 
 try {
-    // CSRF
-    $App->Csrf->validate();
-
     $Controller = new LoginController($App);
     $Response = $Controller->getResponse();
-} catch (ImproperActionException | InvalidCsrfTokenException | InvalidCredentialsException $e) {
-    $Auth->increaseFailedAttempt();
+} 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) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('ip' => $_SERVER['REMOTE_ADDR']), array('IllegalAction' => $e)));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/RegisterController.php b/web/app/controllers/RegisterController.php
index 76fc8c36f..9aaa3281f 100644
--- a/web/app/controllers/RegisterController.php
+++ b/web/app/controllers/RegisterController.php
@@ -10,11 +10,10 @@ declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Services\Check;
 use Exception;
 use Swift_TransportException;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -67,11 +66,11 @@ try {
 } catch (Swift_TransportException $e) {
     // for swift error, don't display error to user as it might contain sensitive information
     // but log it and display general error. See #841
     $App->Log->error('', array('exception' => $e));
     $App->Session->getFlashBag()->add('ko', Tools::error());
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
     $location = '../../register.php';
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e->getMessage())));
diff --git a/web/app/controllers/RequestHandler.php b/web/app/controllers/RequestHandler.php
index 005cc207e..d4c4e7926 100644
--- a/web/app/controllers/RequestHandler.php
+++ b/web/app/controllers/RequestHandler.php
@@ -13,19 +13,19 @@ namespace Elabftw\Elabftw;
 use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Exceptions\ResourceNotFoundException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\AbstractEntity;
 use Elabftw\Models\ApiKeys;
 use Elabftw\Models\Experiments;
 use Elabftw\Models\ItemsTypes;
 use Elabftw\Models\Status;
 use Elabftw\Models\Tags;
+use Elabftw\Models\Teams;
 use Elabftw\Models\Users;
 use Exception;
 use PDOException;
 use Swift_TransportException;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -87,11 +87,13 @@ try {
         } else {
             $res = $Model->update($Params);
         }
     } elseif ($action === 'destroy') {
         if ($Model instanceof Experiments) {
-            if ((!$App->teamConfigArr['deletable_xp'] && !$App->Session->get('is_admin'))
+            $Teams = new Teams($App->Users);
+            $teamConfigArr = $Teams->read(new ContentParams());
+            if ((!$teamConfigArr['deletable_xp'] && !$App->Session->get('is_admin'))
                 || $App->Config->configArr['deletable_xp'] === '0') {
                 throw new ImproperActionException('You cannot delete experiments!');
             }
         }
         $res = $Model->destroy();
@@ -120,11 +122,11 @@ try {
     $Response = new JsonResponse();
     $Response->setData(array(
         'res' => false,
         'msg' => _('Error sending email'),
     ));
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException | ResourceNotFoundException | PDOException $e) {
+} catch (ImproperActionException | UnauthorizedException | ResourceNotFoundException | PDOException $e) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),
     ));
 } catch (IllegalActionException $e) {
diff --git a/web/app/controllers/ResetPasswordController.php b/web/app/controllers/ResetPasswordController.php
index 27b629a15..32da6b13d 100644
--- a/web/app/controllers/ResetPasswordController.php
+++ b/web/app/controllers/ResetPasswordController.php
@@ -17,10 +17,11 @@ use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\QuantumException;
 use Elabftw\Exceptions\ResourceNotFoundException;
+use Elabftw\Models\ExistingUser;
 use Elabftw\Models\Users;
 use Elabftw\Services\Email;
 use Exception;
 use function random_int;
 use function sleep;
@@ -42,11 +43,11 @@ try {
             throw new ImproperActionException(_('Email provided is invalid.'));
         }
 
         // Get data from user
         try {
-            $App->Users->populateFromEmail($email);
+            $App->Users = ExistingUser::fromEmail($email);
             // don't disclose if the email exists in the db or not
         } catch (ResourceNotFoundException $e) {
             // make the response slow to emulate an email being sent if there was an account associated
             sleep(random_int(1, 6));
             throw new QuantumException(_('If the account exists, an email has been sent.'));
diff --git a/web/app/controllers/SortableAjaxController.php b/web/app/controllers/SortableAjaxController.php
index 55bea70d2..30da2b800 100644
--- a/web/app/controllers/SortableAjaxController.php
+++ b/web/app/controllers/SortableAjaxController.php
@@ -13,11 +13,10 @@ namespace Elabftw\Elabftw;
 use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\Experiments;
 use Elabftw\Models\Items;
 use Elabftw\Models\ItemsTypes;
 use Elabftw\Models\Status;
@@ -72,11 +71,11 @@ try {
         default:
             throw new IllegalActionException('Bad table for updateOrdering.');
     }
     $OrderingParams = new OrderingParams($Request->request->get('table'), $Request->request->get('ordering'));
     $Entity->updateOrdering($OrderingParams);
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
+} catch (ImproperActionException | UnauthorizedException $e) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),
     ));
 } catch (IllegalActionException $e) {
diff --git a/web/app/controllers/SysconfigAjaxController.php b/web/app/controllers/SysconfigAjaxController.php
index 6539a9e02..53c67d078 100644
--- a/web/app/controllers/SysconfigAjaxController.php
+++ b/web/app/controllers/SysconfigAjaxController.php
@@ -12,14 +12,12 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Maps\Team;
-use Elabftw\Models\BannedUsers;
 use Elabftw\Models\Idps;
 use Elabftw\Models\Teams;
 use Elabftw\Services\Email;
 use Exception;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -76,16 +74,24 @@ try {
     if ($Request->request->has('idpsDestroy')) {
         $Idps = new Idps((int) $Request->request->get('id'));
         $Idps->destroy();
     }
 
-    // CLEAR BANNED
-    if ($Request->request->has('clear-banned')) {
-        $BannedUsers = new BannedUsers($App->Config);
-        $BannedUsers->clearAll();
+    // 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');
     }
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
+
+    // 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) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),
     ));
 } catch (IllegalActionException $e) {
diff --git a/web/app/controllers/TeamController.php b/web/app/controllers/TeamController.php
index 041a43914..f453194e1 100644
--- a/web/app/controllers/TeamController.php
+++ b/web/app/controllers/TeamController.php
@@ -13,11 +13,10 @@ namespace Elabftw\Elabftw;
 use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Services\Email;
 use Exception;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
 /**
@@ -39,11 +38,11 @@ try {
     if ($Request->request->has('emailTeam')) {
         $Email = new Email($App->Config, $App->Users);
         $sent = $Email->massEmail($Request->request->get('subject'), $Request->request->get('body'), true);
         $App->Session->getFlashBag()->add('ok', sprintf(_('Email sent to %d users'), $sent));
     }
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e)));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/UcpController.php b/web/app/controllers/UcpController.php
index 42fdea510..de7e27269 100644
--- a/web/app/controllers/UcpController.php
+++ b/web/app/controllers/UcpController.php
@@ -13,11 +13,10 @@ namespace Elabftw\Elabftw;
 use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Maps\UserPreferences;
 use Elabftw\Services\Filter;
 use Elabftw\Services\LocalAuth;
 use Elabftw\Services\MfaHelper;
 use Exception;
@@ -104,11 +103,11 @@ try {
     }
     // END TAB 2
 
     $App->Session->getFlashBag()->add('ok', _('Saved'));
     $Response = new RedirectResponse('../../ucp.php?tab=' . $tab . $templateId);
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e->getMessage())));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/controllers/UsersAjaxController.php b/web/app/controllers/UsersAjaxController.php
index fcb3d448b..5e50e1fd5 100644
--- a/web/app/controllers/UsersAjaxController.php
+++ b/web/app/controllers/UsersAjaxController.php
@@ -13,11 +13,10 @@ namespace Elabftw\Elabftw;
 use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\Users;
 use Elabftw\Services\UsersHelper;
 use Exception;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -75,11 +74,11 @@ try {
             throw new IllegalActionException('Sysadmin users cannot be deleted!');
         }
 
         $targetUser->destroy();
     }
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
+} catch (ImproperActionException | UnauthorizedException $e) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),
     ));
 } catch (IllegalActionException $e) {
diff --git a/web/app/controllers/UsersController.php b/web/app/controllers/UsersController.php
index f4027a875..9f67c216d 100644
--- a/web/app/controllers/UsersController.php
+++ b/web/app/controllers/UsersController.php
@@ -12,11 +12,10 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Models\Users;
 use Elabftw\Services\UsersHelper;
 use Exception;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -63,11 +62,11 @@ try {
 
         $targetUser->update($Request->request->all());
     }
 
     $App->Session->getFlashBag()->add('ok', _('Saved'));
-} catch (ImproperActionException | InvalidCsrfTokenException $e) {
+} catch (ImproperActionException $e) {
     // show message to user
     $App->Session->getFlashBag()->add('ko', $e->getMessage());
 } catch (IllegalActionException $e) {
     $App->Log->notice('', array(array('userid' => $App->Session->get('userid')), array('IllegalAction', $e)));
     $App->Session->getFlashBag()->add('ko', Tools::error(true));
diff --git a/web/app/init.inc.php b/web/app/init.inc.php
index fb22e3e9f..cff187ed5 100644
--- a/web/app/init.inc.php
+++ b/web/app/init.inc.php
@@ -13,17 +13,18 @@ use function dirname;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Exceptions\InvalidSchemaException;
 use Elabftw\Exceptions\UnauthorizedException;
 use Elabftw\Models\Config;
-use Elabftw\Models\Users;
 use Elabftw\Services\LoginHelper;
 use Exception;
+use function header;
 use function in_array;
 use Monolog\Logger;
 use PDOException;
 use function setcookie;
+use function stripos;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Session\Session;
 
 /**
  * This must be included on top of every page.
@@ -45,11 +46,18 @@ try {
         throw new ImproperActionException('The config file is missing! Did you run the installer?');
     }
     require_once $configFilePath;
     // END CONFIG.PHP
 
-    // 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 (DatabaseErrorException | PDOException $e) {
@@ -61,96 +69,60 @@ try {
     //   | |   / _ \ '__| '_ \ / _ \ '__| | | / __|   //
     //   | |___  __/ |  | |_) |  __/ |  | |_| \__ \   //
     //    \____\___|_|  |_.__/ \___|_|   \__,_|___/   //
     //                                                //
     //-*-*-*-*-*-*-**-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-//
-    $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
+    // 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',
+    );
 
-            // 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.'));
-        }
+    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);
     }
 
-    // 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'),
-        ));
-    }
+    $App->boot();
+} catch (UnauthorizedException $e) {
+    // KICK USER TO LOGOUT PAGE THAT WILL REDIRECT TO LOGIN PAGE
 
-    // 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';
+    // 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);
     }
 
-    // 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';
+    // 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');
     }
-    // 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
+    exit;
 } catch (ImproperActionException | InvalidSchemaException | Exception $e) {
     // if something went wrong here it should stop whatever is after
     die($e->getMessage());
 }
diff --git a/web/database.php b/web/database.php
index cce0115b0..4e770aaab 100644
--- a/web/database.php
+++ b/web/database.php
@@ -14,10 +14,11 @@ use Elabftw\Controllers\DatabaseController;
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
 use Elabftw\Models\Items;
+use Elabftw\Models\Teams;
 use Exception;
 use Symfony\Component\HttpFoundation\Response;
 
 /**
  * Entry point for database things
@@ -31,11 +32,13 @@ $Response = new Response();
 $Response->prepare($Request);
 
 try {
     $Controller = new DatabaseController($App, new Items($App->Users));
     // show nothing to anon if admin didn't set the DB as public
-    if ($App->Session->has('is_anon') && ($App->teamConfigArr['public_db'] === '0')) {
+    $Teams = new Teams($App->Users);
+    $teamConfigArr = $Teams->read(new ContentParams());
+    if ($App->Session->has('is_anon') && ($teamConfigArr['public_db'] === '0')) {
         throw new ImproperActionException(Tools::error(true));
     }
 
     $Response = $Controller->getResponse();
 } catch (ImproperActionException $e) {
diff --git a/web/login.php b/web/login.php
index 407cd3165..4706733d4 100644
--- a/web/login.php
+++ b/web/login.php
@@ -12,18 +12,15 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\DatabaseErrorException;
 use Elabftw\Exceptions\FilesystemErrorException;
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Models\BannedUsers;
 use Elabftw\Models\Idps;
 use Elabftw\Models\Teams;
 use Elabftw\Services\MfaHelper;
 use Exception;
 use function implode;
-use function in_array;
-use function md5;
 use function str_split;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Response;
 
 /**
@@ -44,26 +41,10 @@ try {
         $message = "eLabFTW works only in HTTPS. Please enable HTTPS on your server. Or click this link : <a href='" .
             $url . "'>$url</a>";
         throw new ImproperActionException($message);
     }
 
-    // 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.'));
-    }
-
     // Show MFA if necessary
     if ($App->Session->has('mfa_auth_required')) {
         $App->pageTitle = _('Two Factor Authentication');
         $template = 'mfa.html';
         $renderArr = array('hideTitle' => true);
@@ -100,11 +81,10 @@ try {
     $Teams = new Teams($App->Users);
     $teamsArr = $Teams->readAll();
 
     $template = 'login.html';
     $renderArr = array(
-        'BannedUsers' => $BannedUsers,
         'idpsArr' => $idpsArr,
         'teamsArr' => $teamsArr,
         'showLocal' => $showLocal,
         'hideTitle' => true,
     );
diff --git a/web/sysconfig.php b/web/sysconfig.php
index 1c26b72ff..df941175b 100644
--- a/web/sysconfig.php
+++ b/web/sysconfig.php
@@ -10,14 +10,13 @@
  */
 declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
-use function count;
 use function dirname;
 use Elabftw\Exceptions\IllegalActionException;
-use Elabftw\Models\BannedUsers;
+use Elabftw\Models\AuthFail;
 use Elabftw\Models\Idps;
 use Elabftw\Models\Teams;
 use Elabftw\Services\UsersHelper;
 use Exception;
 use function file_get_contents;
@@ -38,11 +37,11 @@ $renderArr = array();
 try {
     if (!$App->Session->get('is_sysadmin')) {
         throw new IllegalActionException('Non sysadmin user tried to access sysconfig panel.');
     }
 
-    $BannedUsers = new BannedUsers($App->Config);
+    $AuthFail = new AuthFail();
     $Idps = new Idps();
     $idpsArr = $Idps->readAll();
     $Teams = new Teams($App->Users);
     $teamsArr = $Teams->readAll();
     $teamsStats = $Teams->getAllStats();
@@ -74,11 +73,12 @@ try {
 
     $privacyPolicyTemplate = file_get_contents(dirname(__DIR__) . '/src/templates/privacy-policy.html');
 
     $template = 'sysconfig.html';
     $renderArr = array(
-        'bannedCount' => count($BannedUsers->readAll()),
+        'nologinUsersCount' => $App->Users->getLockedUsersCount(),
+        'lockoutDevicesCount' => $AuthFail->getLockoutDevicesCount(),
         'elabimgVersion' => $elabimgVersion,
         'fromSysconfig' => true,
         'idpsArr' => $idpsArr,
         'isSearching' => $isSearching,
         'langsArr' => $langsArr,

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(),