Commit b52c9045 authored by Baudouin Feildel's avatar Baudouin Feildel
Browse files

Various improvements

* app:user:list command also lists lizmap users
* Add LizmapUser::$status field and the corresponding form UI
* Getting Lizmap user from DDEC-Info user is now more robust
* Admin users can now see all the pages even if they don't have permissions
* Importers use proper database transactions
* UserImporter generates passwords for the created users, the list of created password is displayed in the results page
* Fixed UserRepository::truncateExcept()
parent 81195097
Pipeline #2679 failed with stage
in 50 seconds
......@@ -118,6 +118,7 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/friendsofphp/proxy-manager-lts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/reflection" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/roderik/pwgen-php" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
......
......@@ -115,6 +115,7 @@
<path value="$PROJECT_DIR$/vendor/doctrine/reflection" />
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/roderik/pwgen-php" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="7.1">
......
......@@ -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": "bb22d1e5ba4b9be4190073ed463345cd",
"content-hash": "e42174c6366cfbc1c5986f967ff5af63",
"packages": [
{
"name": "cebe/markdown",
......@@ -2464,6 +2464,57 @@
},
"time": "2021-05-03T11:20:27+00:00"
},
{
"name": "roderik/pwgen-php",
"version": "0.1.8",
"source": {
"type": "git",
"url": "https://github.com/roderik/pwgen-php.git",
"reference": "19763b860eeb94b818cfc67fd6a47b697594e1da"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/roderik/pwgen-php/zipball/19763b860eeb94b818cfc67fd6a47b697594e1da",
"reference": "19763b860eeb94b818cfc67fd6a47b697594e1da",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phpunit/phpunit": "^6.4"
},
"type": "library",
"autoload": {
"psr-4": {
"PWGen\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL"
],
"authors": [
{
"name": "Theodore Ts'o",
"email": "tytso@alum.mit.edu"
},
{
"name": "Superwayne",
"email": "superwayne@superwayne.org"
}
],
"description": "pwgen-php is a simple PHP class which aims to clone the GNU pwgen functionality.",
"keywords": [
"generator",
"password"
],
"support": {
"issues": "https://github.com/roderik/pwgen-php/issues",
"source": "https://github.com/roderik/pwgen-php/tree/master"
},
"time": "2017-12-01T08:47:54+00:00"
},
{
"name": "seld/jsonlint",
"version": "1.8.3",
......
......@@ -139,8 +139,9 @@ div.link.list-group-item:hover {
animation-timing-function: cubic-bezier(0.77, 0, 0.18, 1);
}
.csv-display textarea {
.csv-display textarea, textarea#created-password-list-text {
width: 100%;
min-height: 150px;
max-height: 300px;
margin-bottom: 20px;
font-family: monospace;
......
......@@ -37,6 +37,26 @@
for(let d of csvDisplays) {
window.csvDisplays.push(new CsvDisplay(d));
}
let createdPasswordListCopyButton = document.querySelector("#created-password-list button");
if (createdPasswordListCopyButton) {
createdPasswordListCopyButton.addEventListener("click", () => {
let createdPasswordList = document.querySelector("#created-password-list textarea");
navigator.clipboard.writeText(createdPasswordList.value).then(() => {
let spanMessage = document.querySelector("#created-password-list span");
spanMessage.innerHTML = "Copié!";
window.setTimeout(() => {
spanMessage.innerHTML = "";
}, 2000);
}, () => {
let spanMessage = document.querySelector("#created-password-list span");
spanMessage.innerHTML = "Erreur, impossible de copié le texte...";
window.setTimeout(() => {
spanMessage.innerHTML = "";
}, 2000);
});
});
}
});
function confirmAction(event, text) {
......
......@@ -4,6 +4,7 @@
namespace App\Command;
use App\Entity\Lizmap\LizmapUser;
use App\Entity\User;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Command\Command;
......@@ -17,9 +18,12 @@ class AppUserList extends Command
protected $emDdec;
protected $emLizmap;
public function __construct(ManagerRegistry $doctrine) {
parent::__construct(self::$defaultName);
$this->emDdec = $doctrine->getManager('ddec');
$this->emLizmap = $doctrine->getManager('lizmap');
}
protected function configure()
......@@ -35,12 +39,6 @@ class AppUserList extends Command
/** @var User[] $users */
$users = $this->emDdec->getRepository(User::class)->findAll();
if (count($users) == 0) {
$io->error('No users');
return;
}
$rows = [];
foreach($users as $user) {
$rows[] = [
......@@ -51,6 +49,27 @@ class AppUserList extends Command
];
}
$io->table(["Username", "Email", "Lizmap Login", "Last Login"], $rows);
$io->section("DDEC-Info Users");
if (count($rows) == 0) {
$io->error("No DDEC-Info Users!");
} else {
$io->table(["Username", "Email", "Lizmap Login", "Last Login"], $rows);
}
$rows = [];
/** @var LizmapUser[] $lizmapUsers */
$lizmapUsers = $this->emLizmap->getRepository(LizmapUser::class)->findAll();
foreach($lizmapUsers as $user) {
$rows[] = [
$user->getUsername(),
$user->getEmail()
];
}
$io->section("Lizmap Users");
if (count($rows) == 0) {
$io->error("No Lizmap Users!");
} else {
$io->table(["Username", "Email"], $rows);
}
}
}
......@@ -90,6 +90,19 @@ class LizmapUser implements UserInterface
*/
private $comment;
/**
* @var integer User status, see STATUS_* constants
* @ORM\Column(type="integer")
*/
private $status = self::STATUS_VALIDATED;
public const STATUS_ERASED = -2;
public const STATUS_DISABLED = -1;
public const STATUS_NEW_ACCOUNT_WAITING_EMAIL_VALIDATION = 0;
public const STATUS_VALIDATED = 1;
public const STATUS_VALIDATED_WAITING_EMAIL_VALIDATION = 2;
public const STATUS_VALIDATED_RESET_PASSWORD_ONGOING = 3;
/**
* @var string The user DDEC-Info API Key
* @ORM\Column(type="string")
......@@ -203,6 +216,10 @@ class LizmapUser implements UserInterface
return $this->comment;
}
public function getStatus(): int {
return $this->status;
}
/**
* @return string
*/
......@@ -314,6 +331,13 @@ class LizmapUser implements UserInterface
$this->comment = $comment;
}
public function setStatus(int $status): void {
if ($this->status !== $status)
$this->changed = true;
$this->status = $status;
}
/**
* @param string $apikey
*/
......
......@@ -10,6 +10,7 @@ use App\Form\Field\ButtonLinkType;
use App\Form\Field\StaticType;
use App\Repository\UserRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
......@@ -91,6 +92,19 @@ class LizmapUserType extends AbstractType
'required' => false
])
->add("status", ChoiceType::class, [
'label' => "Statut",
'required' => true,
'choices' => [
"Éffacé" => LizmapUser::STATUS_ERASED,
"Désactivé" => LizmapUser::STATUS_DISABLED,
"Nouveau compte, validation en cours par eamil" => LizmapUser::STATUS_NEW_ACCOUNT_WAITING_EMAIL_VALIDATION,
"Compte validé" => LizmapUser::STATUS_VALIDATED,
"Validé, nouvel email en validation" => LizmapUser::STATUS_VALIDATED_WAITING_EMAIL_VALIDATION,
"Validé, réinitialisation de mot de passe en cours" => LizmapUser::STATUS_VALIDATED_RESET_PASSWORD_ONGOING,
]
])
->add("apikey", StaticType::class, [
'label' => "Clé d'API"
])
......@@ -146,7 +160,6 @@ class LizmapUserType extends AbstractType
* @return User|null
*/
private function getDdecInfoUser(LizmapUser $lizmapUser): ?User {
/** @noinspection PhpIncompatibleReturnTypeInspection */
return $this->userRepository->findOneBy(['lizmap_login' => $lizmapUser->getLogin()]);
}
}
\ No newline at end of file
......@@ -57,11 +57,15 @@ class LizmapUserRepository extends EntityRepository implements ServiceEntityRepo
public function fromDdecInfoUser(User $user): ?LizmapUser {
try {
return $this->createQueryBuilder('u')
->where('u.usr_login = :username')
->setParameter('username', $user->getLizmapLogin())
->getQuery()
->getOneOrNullResult();
if ($user->getLizmapLogin()) {
return $this->createQueryBuilder('u')
->where('u.usr_login = :username')
->setParameter('username', $user->getLizmapLogin())
->getQuery()
->getOneOrNullResult();
} else {
return null;
}
} catch (NonUniqueResultException $e) {
return null;
}
......
......@@ -61,7 +61,7 @@ class UserRepository extends ServiceEntityRepository implements UserLoaderInterf
public function truncateExcept(array $ids): void {
$this->createQueryBuilder('u')
->delete()
->where('u.id not in :ids')
->where('u.id not in (:ids)')
->setParameter('ids', $ids)
->getQuery()
->execute();
......
......@@ -54,11 +54,17 @@ class PageVoter extends Voter
* @return bool
*/
protected function voteOnAttribute($attribute, $page, TokenInterface $token) {
if(!$this->settings->uacEnabled())
if(!$this->settings->uacEnabled()) {
return true;
}
if($attribute == self::ATTRIBUTE_READ)
if ($this->usersTools->getUserFromToken($token)->isAdmin()) {
return true;
}
if($attribute == self::ATTRIBUTE_READ) {
return $this->userCanRead($this->usersTools->getUserFromToken($token), $page);
}
return $this->userCanWrite($this->usersTools->getUserFromToken($token), $page);
}
......
......@@ -128,31 +128,42 @@ abstract class AbstractImporter
'dry' => $this->dry
]);
$this->preCheck();
$f = $this->file->openFile();
$f->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$f->setCsvControl($this->csvDelimiter, "\"", "\\");
$i = 0;
// Disable any time limit on PHP Script
// Apache or other HTTP servers may have a timeout
// This should be handled asynchronously
set_time_limit(0);
if($this->strategy->replace())
$this->clearRepository();
foreach ($f as $line) {
if($this->dataType->hasHeader() && $i === 0)
$i++;
else if($line !== false)
$this->processLine($line, ++$i);
}
if(!$this->dry) {
$this->flushRepository();
try {
if (!$this->dry) {
$this->beginTransaction();
}
$this->preCheck();
$f = $this->file->openFile();
$f->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::DROP_NEW_LINE);
$f->setCsvControl($this->csvDelimiter, "\"", "\\");
$i = 0;
// Disable any time limit on PHP Script
// Apache or other HTTP servers may have a timeout
// This should be handled asynchronously
set_time_limit(0);
if ($this->strategy->replace())
$this->clearRepository();
foreach ($f as $line) {
if ($this->dataType->hasHeader() && $i === 0)
$i++;
else if ($line !== false)
$this->processLine($line, ++$i);
}
if (!$this->dry) {
$this->flushRepository();
$this->commitTransaction();
}
} catch (\Exception $e) {
if (!$this->dry) {
$this->rollbackTransaction();
}
throw $e;
}
}
......@@ -174,6 +185,10 @@ abstract class AbstractImporter
*/
abstract protected function flushRepository(): void;
abstract protected function beginTransaction(): void;
abstract protected function commitTransaction(): void;
abstract protected function rollbackTransaction(): void;
/**
* Process one line
* @param array $line
......
......@@ -4,8 +4,8 @@ namespace App\Service\Importer;
use App\Entity\Group;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
class GroupImporter extends AbstractImporter
......@@ -102,12 +102,18 @@ class GroupImporter extends AbstractImporter
}
}
/**
* Flush repository
*/
protected function flushRepository(): void {
$this->entityManager->flush();
}
protected function beginTransaction(): void {
$this->entityManager->getConnection()->beginTransaction();
}
protected function commitTransaction(): void {
$this->entityManager->getConnection()->commit();
}
protected function rollbackTransaction(): void {
$this->entityManager->getConnection()->rollback();
}
public function getImportLog(): IImportLogger {
return $this->importLogger;
......
......@@ -13,6 +13,8 @@ class ImportLogger implements IImportLogger
{
private $createdUsers = [];
private $createdUsersPassword = [];
private $updatedUsers = [];
private $createdGroups = [];
......@@ -29,7 +31,7 @@ class ImportLogger implements IImportLogger
$this->logger = $logger;
}
public function createUser(User $user): void {
public function createUser(User $user, string $clearTextPassword): void {
if(array_key_exists($user->getId(), $this->createdUsers)) {
$this->logger->error("User already added to create list");
return;
......@@ -41,6 +43,10 @@ class ImportLogger implements IImportLogger
}
$this->createdUsers[$user->getId()] = $user;
$this->createdUsersPassword[] = [
"email" => $user->getEmail(),
"password" => $clearTextPassword,
];
}
public function updateUser(User $user, $oldValues = []): void {
......@@ -165,6 +171,10 @@ class ImportLogger implements IImportLogger
return array_values($this->createdUsers);
}
public function getCreatedUsersPassword(): array {
return $this->createdUsersPassword;
}
/**
* Get the list of users updated during the import
*
......
......@@ -12,11 +12,11 @@ use App\Repository\Lizmap\LizmapUserRepository;
use App\Repository\UserRepository;
use App\Service\ApiKeyGenerator;
use App\Service\UsersTools;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Id\UuidGenerator;
use Exception;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use PWGen\PWGen;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
......@@ -25,9 +25,9 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
*
* Import user file in csv format. The file columns must be:
*
* +-----------+------------+-------+--------------+---------+---------+-----+---------+
* | last name | first name | email | lizmap login | group 1 | group 2 | ... | group n |
* +-----------+------------+-------+--------------+---------+---------+-----+---------+
* +-----------+------------+-------+--------------+----------+---------+---------+-----+---------+
* | last name | first name | email | lizmap login | password | group 1 | group 2 | ... | group n |
* +-----------+------------+-------+--------------+----------+---------+---------+-----+---------+
*
* @package App\Service\Importer
*/
......@@ -124,6 +124,21 @@ class UserImporter extends AbstractImporter
$this->lizmapEntityManager->flush();
}
protected function beginTransaction(): void {
$this->entityManager->getConnection()->beginTransaction();
$this->lizmapEntityManager->getConnection()->beginTransaction();
}
protected function commitTransaction(): void {
$this->entityManager->getConnection()->commit();
$this->lizmapEntityManager->getConnection()->commit();
}
protected function rollbackTransaction(): void {
$this->entityManager->getConnection()->rollback();
$this->lizmapEntityManager->getConnection()->rollback();
}
private const COL_LASTNAME = 0;
private const COL_FIRSTNAME = 1;
private const COL_EMAIL = 2;
......@@ -131,8 +146,6 @@ class UserImporter extends AbstractImporter
private const BASE_COL_GROUP = 4;
/**
* @param array $line
* @param int $i
* @throws ImportException
*/
protected function processLine(array $line, int $i): void {
......@@ -143,16 +156,18 @@ class UserImporter extends AbstractImporter
if(count($line) < 4)
throw new ImportException("Le fichier utilisateur doit comporter au minimum 4 champs par ligne", $i);
$clearTextPassword = $this->getRandomPassword();
/** @var User $user */
$user = $this->userRepository->findOneBy(['email' => $line[self::COL_EMAIL]]);
$lizmapUser = $this->getLizmapUser($line, $i);
$lizmapUser = $this->getLizmapUser($line, $i, $clearTextPassword);
$this->logger->debug("User and lizmap user loaded", [
'user' => $user,
'lizmapUser' => $lizmapUser
]);
// If user doesn't exists create it
// If user doesn't exist create it
if($user === null)
{
$user = new User();
......@@ -160,11 +175,17 @@ class UserImporter extends AbstractImporter
$user->setEmail($line[self::COL_EMAIL]);
$user->setStatus(UserStatus::STATUS_NORMAL);
if($lizmapUser !== null)
$user->setPassword($this->passwordEncoder->encodePassword($user, $clearTextPassword));
if($lizmapUser !== null) {
$user->setLizmapLogin($lizmapUser->getLogin());
if ($this->getStrategy()->patchExisting()) {
$lizmapUser->setPassword($this->passwordEncoder->encodePassword($lizmapUser, $clearTextPassword));
}
}
$this->entityManager->persist($user);
$this->importLogger->createUser($user);
$this->importLogger->createUser($user, $clearTextPassword);
$this->logger->info("Create new user", [
'user' => $user
]);
......@@ -196,25 +217,22 @@ class UserImporter extends AbstractImporter
}
// If user contains some groups
if(count($line) > 4)
if(count($line) > self::BASE_COL_GROUP)
$this->addUserToGroups($user, array_slice($line, self::BASE_COL_GROUP), $i);
}
private function getRandomBytes(int $length = 10): string {
try {
return bin2hex(random_bytes($length));
} catch (Exception $e) {
return uniqid(time(), true);
}
private function getRandomPassword(): string {
$generator = new PWGen(10, false, true, true, false, false);
return $generator->generate();
}
/**
* @param string[] $line
* @param int $i
* @param int $i
* @param string $clearTextPassword
* @return LizmapUser|null
* @throws ImportException
*/
private function getLizmapUser(array $line, int $i): ?LizmapUser {
private function getLizmapUser(array $line, int $i, string $clearTextPassword): ?LizmapUser {
if(strlen($line[self::COL_LIZMAP_LOGIN]) == 0)
return null;
......@@ -229,7 +247,7 @@ class UserImporter extends AbstractImporter
$lizmapUser->setFirstname($line[self::COL_FIRSTNAME]);
$lizmapUser->setLastname($line[self::COL_LASTNAME]);
$lizmapUser->setEmail($line[self::COL_EMAIL]);
$lizmapUser->setPassword($this->passwordEncoder->encodePassword($lizmapUser, $this->getRandomBytes()));