CVE-2021-41171 (published 2021-10-22T19:15Z)

eLabFTW is an open source electronic lab notebook manager for research teams. In versions of eLabFTW before 4.1.0, it allows attackers to bypass a brute-force protection mechanism by using many different forged PHPSESSID values in HTTP Cookie header. This issue has been addressed by implementing brute force login protection, as recommended by Owasp with Device Cookies. This mechanism will not impact users and will effectively thwart any brute-force attempts at guessing passwords. The only correct way to address this is to upgrade to version 4.1.0. Adding rate limitation upstream of the eLabFTW service is of course a valid option, with or without upgrading.

Severity: MEDIUM
CVSS v2 base score: 4.0 (AV:N/AC:L/Au:S/C:P/I:N/A:N)
CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-307
cwe_name: Improper Restriction of Excessive Authentication Attempts
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.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/elabftw/elabftw
commit_details: repo_url: https://github.com/elabftw/elabftw
author: Nicolas CARPi
author_date: 2021-08-08 21:13:04+02:00
msg: 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
repository: repo_url: https://github.com/elabftw/elabftw
repo_name: elabftw/elabftw
description: :notebook: eLabFTW is the most popular open source electronic lab notebook for research labs.
repo_language: PHP
owner: elabftw
file_changes: filename: composer.json
change_type: ModificationType.MODIFY
diff: @@ -38,7 +38,8 @@
     "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",

programming_language: Jupyter Notebook
filename: composer.lock
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@
         "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",
@@ -574,6 +574,141 @@
             },
             "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",

programming_language: Jupyter Notebook
filename: App.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,22 +6,30 @@
  * @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
@@ -37,14 +45,16 @@ class App
 
     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)
@@ -61,7 +71,7 @@ class App
         $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();
     }
 
@@ -91,29 +101,39 @@ class App
         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();
     }
 
     /**
@@ -127,4 +147,43 @@ class App
     {
         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);
+    }
 }

programming_language: PHP
filename: Auth.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,7 +6,6 @@
  * @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;
 
@@ -19,27 +18,16 @@ 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;
     }
 
     /**
@@ -57,52 +45,8 @@ class Auth implements AuthInterface
         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')
@@ -129,8 +73,6 @@ class Auth implements AuthInterface
             // 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

programming_language: PHP
filename: Csrf.php
change_type: ModificationType.MODIFY
diff: @@ -1,19 +1,16 @@
-<?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;
 
@@ -61,7 +58,8 @@ class Csrf
             $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.'));
         }
     }
 

programming_language: PHP
filename: Db.php
change_type: ModificationType.MODIFY
diff: @@ -112,10 +112,10 @@ final class Db
         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;
     }
@@ -129,7 +129,7 @@ final class Db
     {
         $res = $this->connection->query($sql);
         if ($res === false) {
-            throw new DatabaseErrorException('Error executing query!');
+            throw new DatabaseErrorException();
         }
 
         return $res;

programming_language: PHP
filename: Update.php
change_type: ModificationType.MODIFY
diff: @@ -39,7 +39,7 @@ class Update
 
     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();
     }
@@ -47,7 +47,7 @@ class Update
     /**
      * Get the current required schema
      */
-    public function getRequiredSchema(): int
+    public static function getRequiredSchema(): int
     {
         return self::REQUIRED_SCHEMA;
     }
@@ -57,9 +57,7 @@ class Update
      */
     public function checkSchema(): void
     {
-        $currentSchema = (int) $this->Config->configArr['schema'];
-
-        if ($currentSchema !== self::REQUIRED_SCHEMA) {
+        if ($this->currentSchema !== self::REQUIRED_SCHEMA) {
             throw new InvalidSchemaException();
         }
     }
@@ -69,28 +67,26 @@ class Update
      */
     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();
             }

programming_language: PHP
filename: CheckDatabase.php
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,6 @@ 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;
@@ -47,10 +46,9 @@ class CheckDatabase extends Command
     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',

programming_language: PHP
filename: UpdateDatabase.php
change_type: ModificationType.MODIFY
diff: @@ -56,7 +56,7 @@ class UpdateDatabase extends Command
             ));
 
             $Config = Config::getConfig();
-            $Update = new Update($Config, new Sql());
+            $Update = new Update((int) $Config->configArr['schema'], new Sql());
             $Update->runUpdateScript();
             $output->writeln('All done.');
         }

programming_language: PHP
filename: LoginController.php
change_type: ModificationType.MODIFY
diff: @@ -15,10 +15,15 @@ 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;
@@ -27,6 +32,7 @@ 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;
@@ -41,6 +47,8 @@ class LoginController implements ControllerInterface
 {
     public function __construct(private App $App)
     {
+        $App->Csrf->validate();
+        $this->validateDeviceToken();
     }
 
     public function getResponse(): Response
@@ -137,7 +145,6 @@ class LoginController implements ControllerInterface
         $LoginHelper->login((bool) $icanhazcookies);
 
         // cleanup
-        $this->App->Session->remove('failed_attempt');
         $this->App->Session->remove('rememberme');
         $this->App->Session->remove('auth_userid');
 
@@ -146,6 +153,35 @@ class LoginController implements ControllerInterface
         );
     }
 
+    /**
+     * 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) {

programming_language: PHP
filename: DatabaseErrorException.php
change_type: ModificationType.MODIFY
diff: @@ -1,11 +1,10 @@
-<?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;
 
@@ -14,17 +13,10 @@ 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);
     }
 }

programming_language: PHP
filename: FilesystemErrorException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,26 +6,12 @@
  * @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);
-    }
 }

programming_language: PHP
filename: IllegalActionException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,26 +6,12 @@
  * @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);
-    }
 }

programming_language: PHP
filename: ImproperActionException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,26 +6,12 @@
  * @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);
-    }
 }

programming_language: PHP
filename: InvalidCredentialsException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,7 +6,6 @@
  * @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;
 
@@ -15,13 +14,10 @@ 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);
     }
 }

programming_language: PHP
filename: InvalidCsrfTokenException.php
change_type: ModificationType.DELETE
diff: @@ -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);
-    }
-}

programming_language: unknown
filename: InvalidDeviceTokenException.php
change_type: ModificationType.ADD
diff: @@ -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.'));
+    }
+}

programming_language: PHP
filename: InvalidSchemaException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,7 +6,6 @@
  * @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;
 

programming_language: PHP
filename: ProcessFailedException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,26 +6,12 @@
  * @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);
-    }
 }

programming_language: PHP
filename: QuantumException.php
change_type: ModificationType.MODIFY
diff: @@ -1,11 +1,10 @@
-<?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;
 

programming_language: PHP
filename: ResourceNotFoundException.php
change_type: ModificationType.MODIFY
diff: @@ -1,11 +1,10 @@
-<?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;
 
@@ -15,16 +14,9 @@ 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');

programming_language: PHP
filename: UnauthorizedException.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @package   Elabftw\Elabftw
  * @author    Nicolas CARPi <nico-git@deltablot.email>
@@ -6,7 +6,6 @@
  * @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;
 
@@ -15,16 +14,9 @@ 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');

programming_language: PHP
filename: WithMessageException.php
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+}

programming_language: PHP
filename: AnonymousUser.php
change_type: ModificationType.ADD
diff: @@ -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';
+    }
+}

programming_language: PHP
filename: AuthFail.php
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+}

programming_language: PHP
filename: AuthenticatedUser.php
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+}

programming_language: PHP
filename: BannedUsers.php
change_type: ModificationType.DELETE
diff: @@ -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();
-    }
-}

programming_language: unknown
filename: Config.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,6 @@ 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;
@@ -89,7 +88,7 @@ final class Config
         $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];
@@ -155,8 +154,7 @@ final class Config
      */
     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'),

programming_language: PHP
filename: ExistingUser.php
change_type: ModificationType.ADD
diff: @@ -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;
+    }
+}

programming_language: PHP
filename: Tags.php
change_type: ModificationType.MODIFY
diff: @@ -76,7 +76,7 @@ class Tags implements CrudInterface
         $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;

programming_language: PHP
filename: Uploads.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -403,18 +402,10 @@ 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,

programming_language: PHP
filename: Users.php
change_type: ModificationType.MODIFY
diff: @@ -13,7 +13,6 @@ 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;
@@ -64,18 +63,24 @@ class Users
     }
 
     /**
-     * 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();
@@ -187,24 +192,6 @@ class Users
         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
      *
@@ -292,6 +279,14 @@ class Users
         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
      *

programming_language: PHP
filename: ValidatedUser.php
change_type: ModificationType.ADD
diff: @@ -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);
+    }
+}

programming_language: PHP
filename: DeviceToken.php
change_type: ModificationType.ADD
diff: @@ -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;
+    }
+}

programming_language: PHP
filename: DeviceTokenValidator.php
change_type: ModificationType.ADD
diff: @@ -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();
+        }
+    }
+}

programming_language: PHP
filename: ExternalAuth.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,8 @@ 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;
 
 /**
@@ -48,9 +49,8 @@ class ExternalAuth implements AuthInterface
         }
 
         // 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
@@ -58,8 +58,7 @@ class ExternalAuth implements AuthInterface
                 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'];

programming_language: PHP
filename: ImportCsv.php
change_type: ModificationType.MODIFY
diff: @@ -84,7 +84,7 @@ class ImportCsv extends AbstractImport
             $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++;
         }

programming_language: PHP
filename: LdapAuth.php
change_type: ModificationType.MODIFY
diff: @@ -15,8 +15,10 @@ 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;
@@ -42,7 +44,7 @@ class LdapAuth implements AuthInterface
         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
@@ -50,12 +52,11 @@ class LdapAuth implements AuthInterface
             $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
@@ -81,7 +82,7 @@ class LdapAuth implements AuthInterface
             // 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'];

programming_language: PHP
filename: LocalAuth.php
change_type: ModificationType.MODIFY
diff: @@ -12,9 +12,12 @@ 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;
@@ -65,19 +68,18 @@ class LocalAuth implements AuthInterface
         $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'];
     }
@@ -128,7 +130,7 @@ class LocalAuth implements AuthInterface
         $this->Db->execute($req);
 
         if ($req->rowCount() !== 1) {
-            throw new InvalidCredentialsException();
+            throw new InvalidCredentialsException($this->userid);
         }
     }
 
@@ -141,7 +143,7 @@ class LocalAuth implements AuthInterface
         $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)) {

programming_language: PHP
filename: LoginHelper.php
change_type: ModificationType.MODIFY
diff: @@ -36,15 +36,12 @@ class LoginHelper
      */
     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();
     }
 
     /**
@@ -58,6 +55,21 @@ class LoginHelper
         $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
      */

programming_language: PHP
filename: MfaAuth.php
change_type: ModificationType.MODIFY
diff: @@ -32,7 +32,7 @@ class MfaAuth implements AuthInterface
         $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;

programming_language: PHP
filename: Populate.php
change_type: ModificationType.MODIFY
diff: @@ -114,7 +114,7 @@ class Populate
         $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']);
 

programming_language: PHP
filename: SamlAuth.php
change_type: ModificationType.MODIFY
diff: @@ -18,8 +18,10 @@ 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;
 
@@ -151,10 +153,8 @@ class SamlAuth implements AuthInterface
 
     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
@@ -180,7 +180,7 @@ class SamlAuth implements AuthInterface
             }
 
             // 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;
     }

programming_language: PHP
filename: SessionAuth.php
change_type: ModificationType.DELETE
diff: @@ -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;
-    }
-}

programming_language: unknown
filename: schema63.sql
change_type: ModificationType.MODIFY
diff: @@ -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;

programming_language: SQL
filename: structure.sql
change_type: ModificationType.MODIFY
diff: @@ -39,28 +39,23 @@ CREATE TABLE `api_keys` (
 ) 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`
 --
 
 -- --------------------------------------------------------
@@ -413,6 +408,17 @@ CREATE TABLE `items_types` (
 --
 
 -- --------------------------------------------------------
+--
+-- 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`
@@ -666,6 +672,8 @@ CREATE TABLE `users` (
   `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;
 

programming_language: SQL
filename: admin.html
change_type: ModificationType.MODIFY
diff: @@ -36,91 +36,91 @@
       {{ 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'>",
@@ -135,7 +135,7 @@
       <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>
 

programming_language: HTML
filename: failed-attempt.html
change_type: ModificationType.DELETE
diff: @@ -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 %}

programming_language: unknown
filename: head.html
change_type: ModificationType.MODIFY
diff: @@ -6,7 +6,7 @@
  * @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' />
@@ -75,7 +75,7 @@
 
             <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>
 

programming_language: HTML
filename: login.html
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
 {% 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'>

programming_language: HTML
filename: sysconfig.html
change_type: ModificationType.MODIFY
diff: @@ -329,9 +329,19 @@ MySQL database structure version: {{ App.Config.configArr.schema }}</p>
   </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>
 
 

programming_language: HTML
filename: phpstan.neon
change_type: ModificationType.MODIFY
diff: @@ -10,9 +10,6 @@ parameters:
     -
       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

programming_language: Markdown
filename: sysconfig.ts
change_type: ModificationType.MODIFY
diff: @@ -8,7 +8,6 @@
 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() {
@@ -158,19 +157,24 @@ $(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);

programming_language: JavaScript
filename: docker-compose.yml
change_type: ModificationType.MODIFY
diff: @@ -6,7 +6,7 @@ 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:
@@ -39,6 +39,7 @@ services:
             - TZ=Europe/Paris
             - USE_REDIS=false
             - ENABLE_IPV6=true
+            - DEV_MODE=true
         volumes:
             - ..:/elabftw
             - ../_coverage:/elabftw/tests/_output/coverage

programming_language: Shell
filename: AuthTest.php
change_type: ModificationType.MODIFY
diff: @@ -9,11 +9,9 @@
 
 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
 {
@@ -21,26 +19,12 @@ class AuthTest extends \PHPUnit\Framework\TestCase
 
     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'));
-    }
-     */
 }

programming_language: PHP
filename: AuthFailTest.php
change_type: ModificationType.ADD
diff: @@ -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();
+    }
+}

programming_language: PHP
filename: BannedUsersTest.php
change_type: ModificationType.DELETE
diff: @@ -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()));
-    }
-}

programming_language: unknown
filename: UsersTest.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,11 @@
 <?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;
 

programming_language: PHP
filename: DeviceTokenTest.php
change_type: ModificationType.ADD
diff: @@ -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));
+    }
+}

programming_language: PHP
filename: DeviceTokenValidatorTest.php
change_type: ModificationType.ADD
diff: @@ -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();
+    }
+}

programming_language: PHP
filename: LocalAuthTest.php
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ declare(strict_types=1);
 namespace Elabftw\Services;
 
 use Elabftw\Exceptions\InvalidCredentialsException;
+use Elabftw\Exceptions\QuantumException;
 
 class LocalAuthTest extends \PHPUnit\Framework\TestCase
 {
@@ -31,7 +32,7 @@ class LocalAuthTest extends \PHPUnit\Framework\TestCase
 
     public function testTryAuthWithInvalidEmail(): void
     {
-        $this->expectException(InvalidCredentialsException::class);
+        $this->expectException(QuantumException::class);
         $AuthService = new LocalAuth('invalid@example.com', 'nopenope');
     }
 

programming_language: PHP
filename: SessionAuthTest.php
change_type: ModificationType.DELETE
diff: @@ -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);
-    }
-}

programming_language: unknown
filename: admin.php
change_type: ModificationType.MODIFY
diff: @@ -49,6 +49,7 @@ try {
 
     $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();
@@ -80,6 +81,7 @@ try {
         'isSearching' => $isSearching,
         'itemsTypesArr' => $itemsTypesArr,
         'statusArr' => $statusArr,
+        'teamConfigArr' => $teamConfigArr,
         'teamGroupsArr' => $teamGroupsArr,
         'visibilityArr' => $TeamGroups->getVisibilityList(),
         'teamsArr' => $teamsArr,

programming_language: PHP
filename: AdminController.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,6 @@ 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;
@@ -52,7 +51,7 @@ try {
 
     // 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) {

programming_language: PHP
filename: ApiController.php
change_type: ModificationType.MODIFY
diff: @@ -17,36 +17,17 @@ 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) {

programming_language: PHP
filename: CreateUserController.php
change_type: ModificationType.MODIFY
diff: @@ -12,7 +12,7 @@ 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;
@@ -47,12 +47,11 @@ try {
     }
 
     // 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'),
     );
 
@@ -65,7 +64,7 @@ try {
     // 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) {

programming_language: PHP
filename: EntityAjaxController.php
change_type: ModificationType.MODIFY
diff: @@ -13,7 +13,6 @@ 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;
@@ -199,7 +198,7 @@ try {
             '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(),

programming_language: PHP
filename: IdpsController.php
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,5 @@
 <?php
 /**
- * app/controllers/IdpsController.php
- *
  * @author Nicolas CARPi <nico-git@deltablot.email>
  * @copyright 2012 Nicolas CARPi
  * @see https://www.elabftw.net Official website

programming_language: PHP
filename: ImportController.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,6 @@ 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;
@@ -51,7 +50,7 @@ try {
     $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) {

programming_language: PHP
filename: LoginController.php
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,4 @@
-<?php
+<?php declare(strict_types=1);
 /**
  * @author Nicolas CARPi <nico-git@deltablot.email>
  * @copyright 2012 Nicolas CARPi
@@ -6,7 +6,6 @@
  * @license AGPL-3.0
  * @package elabftw
  */
-declare(strict_types=1);
 
 namespace Elabftw\Elabftw;
 
@@ -17,7 +16,8 @@ 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;
 
@@ -29,13 +29,14 @@ $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) {

programming_language: PHP
filename: RegisterController.php
change_type: ModificationType.MODIFY
diff: @@ -12,7 +12,6 @@ namespace Elabftw\Elabftw;
 
 use Elabftw\Exceptions\IllegalActionException;
 use Elabftw\Exceptions\ImproperActionException;
-use Elabftw\Exceptions\InvalidCsrfTokenException;
 use Elabftw\Services\Check;
 use Exception;
 use Swift_TransportException;
@@ -69,7 +68,7 @@ try {
     // 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';

programming_language: PHP
filename: RequestHandler.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -24,6 +23,7 @@ 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;
@@ -89,7 +89,9 @@ try {
         }
     } 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!');
             }
@@ -122,7 +124,7 @@ try {
         '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(),

programming_language: PHP
filename: ResetPasswordController.php
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,7 @@ 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;
@@ -44,7 +45,7 @@ try {
 
         // 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

programming_language: PHP
filename: SortableAjaxController.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -74,7 +73,7 @@ try {
     }
     $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(),

programming_language: PHP
filename: SysconfigAjaxController.php
change_type: ModificationType.MODIFY
diff: @@ -14,10 +14,8 @@ 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;
@@ -78,12 +76,20 @@ try {
         $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(),

programming_language: PHP
filename: TeamController.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -41,7 +40,7 @@ try {
         $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) {

programming_language: PHP
filename: UcpController.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -106,7 +105,7 @@ try {
 
     $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) {

programming_language: PHP
filename: UsersAjaxController.php
change_type: ModificationType.MODIFY
diff: @@ -15,7 +15,6 @@ 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;
@@ -77,7 +76,7 @@ try {
 
         $targetUser->destroy();
     }
-} catch (ImproperActionException | InvalidCsrfTokenException | UnauthorizedException $e) {
+} catch (ImproperActionException | UnauthorizedException $e) {
     $Response->setData(array(
         'res' => false,
         'msg' => $e->getMessage(),

programming_language: PHP
filename: UsersController.php
change_type: ModificationType.MODIFY
diff: @@ -14,7 +14,6 @@ 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;
@@ -65,7 +64,7 @@ try {
     }
 
     $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) {

programming_language: PHP
filename: init.inc.php
change_type: ModificationType.MODIFY
diff: @@ -15,13 +15,14 @@ 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;
 
@@ -47,7 +48,14 @@ try {
     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 {
@@ -63,93 +71,57 @@ 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());

programming_language: PHP
filename: database.php
change_type: ModificationType.MODIFY
diff: @@ -16,6 +16,7 @@ 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;
 
@@ -33,7 +34,9 @@ $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));
     }
 

programming_language: PHP
filename: login.php
change_type: ModificationType.MODIFY
diff: @@ -14,14 +14,11 @@ 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;
@@ -46,22 +43,6 @@ try {
         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');
@@ -102,7 +83,6 @@ try {
 
     $template = 'login.html';
     $renderArr = array(
-        'BannedUsers' => $BannedUsers,
         'idpsArr' => $idpsArr,
         'teamsArr' => $teamsArr,
         'showLocal' => $showLocal,

programming_language: PHP
filename: sysconfig.php
change_type: ModificationType.MODIFY
diff: @@ -12,10 +12,9 @@ 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;
@@ -40,7 +39,7 @@ try {
         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);
@@ -76,7 +75,8 @@ try {
 
     $template = 'sysconfig.html';
     $renderArr = array(
-        'bannedCount' => count($BannedUsers->readAll()),
+        'nologinUsersCount' => $App->Users->getLockedUsersCount(),
+        'lockoutDevicesCount' => $AuthFail->getLockoutDevicesCount(),
         'elabimgVersion' => $elabimgVersion,
         'fromSysconfig' => true,
         'idpsArr' => $idpsArr,

programming_language: PHP