Merge branch 'master' into permission_rework

This commit is contained in:
Jan Böhmer 2022-10-30 17:35:57 +01:00
commit 33f8d2ba9e
109 changed files with 5095 additions and 2860 deletions

View file

@ -81,6 +81,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
abstract class BaseAdminController extends AbstractController
{
protected $entity_class = '';
@ -419,7 +421,7 @@ abstract class BaseAdminController extends AbstractController
/** @var AbstractPartsContainingRepository $repo */
$repo = $this->entityManager->getRepository($this->entity_class);
if ($repo->getPartsCount($entity) > 0) {
$this->addFlash('error', 'entity.delete.must_not_contain_parts');
$this->addFlash('error', t('entity.delete.must_not_contain_parts', ['%PATH%' => $entity->getFullPath()]));
return false;
}
@ -442,7 +444,18 @@ abstract class BaseAdminController extends AbstractController
//Check if we need to remove recursively
if ($entity instanceof AbstractStructuralDBElement && $request->get('delete_recursive', false)) {
$recursionHelper->delete($entity, false);
$can_delete = true;
//Check if any of the children can not be deleted, cause it contains parts
$recursionHelper->execute($entity, function (AbstractStructuralDBElement $element) use (&$can_delete) {
if(!$this->deleteCheck($element)) {
$can_delete = false;
}
});
if($can_delete) {
$recursionHelper->delete($entity, false);
} else {
return $this->redirectToRoute($this->route_base.'_edit', ['id' => $entity->getID()]);
}
} else {
if ($entity instanceof AbstractStructuralDBElement) {
$parent = $entity->getParent();

View file

@ -80,6 +80,8 @@ class PartListsController extends AbstractController
*/
public function tableAction(Request $request, PartsTableActionHandler $actionHandler): Response
{
$this->denyAccessUnlessGranted('@parts.edit');
$redirect = $request->request->get('redirect_back');
$ids = $request->request->get('ids');
$action = $request->request->get('action');
@ -137,6 +139,8 @@ class PartListsController extends AbstractController
*/
protected function showListWithFilter(Request $request, string $template, ?callable $filter_changer = null, ?callable $form_changer = null, array $additonal_template_vars = [], array $additional_table_vars = []): Response
{
$this->denyAccessUnlessGranted('@parts.read');
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new PartFilter($this->nodesListBuilder);

View file

@ -113,7 +113,10 @@ class UserController extends AdminPages\BaseAdminController
$entity->setGoogleAuthenticatorSecret(null);
$entity->setBackupCodes([]);
//Remove all U2F keys
foreach ($entity->getU2FKeys() as $key) {
foreach ($entity->getLegacyU2FKeys() as $key) {
$em->remove($key);
}
foreach ($entity->getWebAuthnKeys() as $key) {
$em->remove($key);
}
//Invalidate trusted devices

View file

@ -44,6 +44,7 @@ namespace App\Controller;
use App\Entity\UserSystem\U2FKey;
use App\Entity\UserSystem\User;
use App\Entity\UserSystem\WebauthnKey;
use App\Events\SecurityEvent;
use App\Events\SecurityEvents;
use App\Form\TFAGoogleSettingsType;
@ -130,6 +131,7 @@ class UserSettingsController extends AbstractController
}
if ($this->isCsrfTokenValid('delete'.$user->getId(), $request->request->get('_token'))) {
//Handle U2F key removal
if ($request->request->has('key_id')) {
$key_id = $request->request->get('key_id');
$key_repo = $entityManager->getRepository(U2FKey::class);
@ -138,14 +140,14 @@ class UserSettingsController extends AbstractController
if (null === $u2f) {
$this->addFlash('danger', 'tfa_u2f.u2f_delete.not_existing');
throw new RuntimeException('Key not existing!');
return $this->redirectToRoute('user_settings');
}
//User can only delete its own U2F keys
if ($u2f->getUser() !== $user) {
$this->addFlash('danger', 'tfa_u2f.u2f_delete.access_denied');
throw new RuntimeException('You can only delete your own U2F keys!');
return $this->redirectToRoute('user_settings');
}
$backupCodeManager->disableBackupCodesIfUnused($user);
@ -153,6 +155,31 @@ class UserSettingsController extends AbstractController
$entityManager->flush();
$this->addFlash('success', 'tfa.u2f.u2f_delete.success');
$security_event = new SecurityEvent($user);
$this->eventDispatcher->dispatch($security_event, SecurityEvents::U2F_REMOVED);
} else if ($request->request->has('webauthn_key_id')) {
$key_id = $request->request->get('webauthn_key_id');
$key_repo = $entityManager->getRepository(WebauthnKey::class);
/** @var WebauthnKey|null $key */
$key = $key_repo->find($key_id);
if (null === $key) {
$this->addFlash('error', 'tfa_u2f.u2f_delete.not_existing');
return $this->redirectToRoute('user_settings');
}
//User can only delete its own U2F keys
if ($key->getUser() !== $user) {
$this->addFlash('error', 'tfa_u2f.u2f_delete.access_denied');
return $this->redirectToRoute('user_settings');
}
$backupCodeManager->disableBackupCodesIfUnused($user);
$entityManager->remove($key);
$entityManager->flush();
$this->addFlash('success', 'tfa.u2f.u2f_delete.success');
$security_event = new SecurityEvent($user);
$this->eventDispatcher->dispatch($security_event, SecurityEvents::U2F_REMOVED);
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Controller;
use App\Entity\UserSystem\WebauthnKey;
use Doctrine\ORM\EntityManagerInterface;
use Jbtronics\TFAWebauthn\Services\TFAWebauthnRegistrationHelper;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use function Symfony\Component\Translation\t;
class WebauthnKeyRegistrationController extends AbstractController
{
/**
* @Route("/webauthn/register", name="webauthn_register")
*/
public function register(Request $request, TFAWebauthnRegistrationHelper $registrationHelper, EntityManagerInterface $em)
{
//If form was submitted, check the auth response
if ($request->getMethod() === 'POST') {
$webauthnResponse = $request->request->get('_auth_code');
//Retrieve other data from the form, that you want to store with the key
$keyName = $request->request->get('keyName');
if (empty($keyName)) {
$keyName = 'Key ' . date('Y-m-d H:i:s');
}
//Check the response
try {
$new_key = $registrationHelper->checkRegistrationResponse($webauthnResponse);
} catch (\Exception $exception) {
$this->addFlash('error', t('tfa_u2f.add_key.registration_error'));
return $this->redirectToRoute('webauthn_register');
}
$keyEntity = WebauthnKey::fromRegistration($new_key);
$keyEntity->setName($keyName);
$keyEntity->setUser($this->getUser());
$em->persist($keyEntity);
$em->flush();
$this->addFlash('success', 'Key registered successfully');
return $this->redirectToRoute('user_settings');
}
return $this->render(
'Security/Webauthn/webauthn_register.html.twig',
[
'registrationRequest' => $registrationHelper->generateRegistrationRequestAsJSON(),
]
);
}
}

View file

@ -226,7 +226,7 @@ final class AttachmentDataTable implements DataTableTypeInterface
private function getQuery(QueryBuilder $builder): void
{
$builder->distinct()->select('attachment')
$builder->select('attachment')
->addSelect('attachment_type')
//->addSelect('element')
->from(Attachment::class, 'attachment')

View file

@ -297,7 +297,7 @@ class LogDataTable implements DataTableTypeInterface
protected function getQuery(QueryBuilder $builder, array $options): void
{
$builder->distinct()->select('log')
$builder->select('log')
->addSelect('user')
->from(AbstractLogEntry::class, 'log')
->leftJoin('log.user', 'user');

View file

@ -292,8 +292,8 @@ final class PartsDataTable implements DataTableTypeInterface
private function getQuery(QueryBuilder $builder): void
{
$builder->distinct()->select('part')
//Distinct is very slow here, do not add this here (also I think this is not needed here, as the id column is always distinct)
$builder->select('part')
->addSelect('category')
->addSelect('footprint')
->addSelect('manufacturer')

View file

@ -34,7 +34,12 @@ use LogicException;
* Class Attachment.
*
* @ORM\Entity(repositoryClass="App\Repository\AttachmentRepository")
* @ORM\Table(name="`attachments`")
* @ORM\Table(name="`attachments`", indexes={
* @ORM\Index(name="attachments_idx_id_element_id_class_name", columns={"id", "element_id", "class_name"}),
* @ORM\Index(name="attachments_idx_class_name_id", columns={"class_name", "id"}),
* @ORM\Index(name="attachment_name_idx", columns={"name"}),
* @ORM\Index(name="attachment_element_idx", columns={"class_name", "element_id"})
* })
* @ORM\InheritanceType("SINGLE_TABLE")
* @ORM\DiscriminatorColumn(name="class_name", type="string")
* @ORM\DiscriminatorMap({
@ -104,7 +109,7 @@ abstract class Attachment extends AbstractNamedDBElement
/**
* @var AttachmentType
* @ORM\ManyToOne(targetEntity="AttachmentType", inversedBy="attachments_with_type")
* @ORM\JoinColumn(name="type_id", referencedColumnName="id")
* @ORM\JoinColumn(name="type_id", referencedColumnName="id", nullable=false)
* @Selectable()
* @Assert\NotNull(message="validator.attachment.must_not_be_null")
*/

View file

@ -34,7 +34,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class AttachmentType.
*
* @ORM\Entity(repositoryClass="App\Repository\StructuralDBElementRepository")
* @ORM\Table(name="`attachment_types`")
* @ORM\Table(name="`attachment_types`", indexes={
* @ORM\Index(name="attachment_types_idx_name", columns={"name"}),
* @ORM\Index(name="attachment_types_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class AttachmentType extends AbstractStructuralDBElement
{

View file

@ -93,7 +93,7 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
* @NoneOfItsChildren()
* @Groups({"include_parents"})
*/
protected $parent;
protected $parent = null;
/** @var string[] all names of all parent elements as a array of strings,
* the last array element is the name of the element itself
@ -271,16 +271,17 @@ abstract class AbstractStructuralDBElement extends AttachmentContainingDBElement
*/
public function getSubelements(): iterable
{
return $this->children;
return $this->children ?? new ArrayCollection();
}
/**
* @see getSubelements()
* @return Collection<static>|iterable
* @psalm-return Collection<int, static>
*/
public function getChildren(): iterable
{
return $this->children;
return $this->getSubelements();
}
public function isNotSelectable(): bool

View file

@ -78,12 +78,12 @@ class DevicePart extends AbstractDBElement
* @ORM\ManyToOne(targetEntity="Device", inversedBy="parts")
* @ORM\JoinColumn(name="id_device", referencedColumnName="id")
*/
protected Device $device;
protected ?Device $device = null;
/**
* @var Part
* @ORM\ManyToOne(targetEntity="App\Entity\Parts\Part")
* @ORM\JoinColumn(name="id_part", referencedColumnName="id")
*/
protected Part $part;
protected ?Part $part = null;
}

View file

@ -71,7 +71,11 @@ use Psr\Log\LogLevel;
* This entity describes a entry in the event log.
*
* @ORM\Entity(repositoryClass="App\Repository\LogEntryRepository")
* @ORM\Table("log")
* @ORM\Table("log", indexes={
* @ORM\Index(name="log_idx_type", columns={"type"}),
* @ORM\Index(name="log_idx_type_target", columns={"type", "target_type", "target_id"}),
* @ORM\Index(name="log_idx_datetime", columns={"datetime"}),
* })
* @ORM\InheritanceType("SINGLE_TABLE")
* @ORM\DiscriminatorColumn(name="type", type="smallint")
* @ORM\DiscriminatorMap({

View file

@ -34,7 +34,11 @@ use function sprintf;
/**
* @ORM\Entity(repositoryClass="App\Repository\ParameterRepository")
* @ORM\Table("parameters")
* @ORM\Table("parameters", indexes={
* @ORM\Index(name="parameter_name_idx", columns={"name"}),
* @ORM\Index(name="parameter_group_idx", columns={"param_group"}),
* @ORM\Index(name="parameter_type_element_idx", columns={"type", "element_id"})
* })
* @ORM\InheritanceType("SINGLE_TABLE")
* @ORM\DiscriminatorColumn(name="type", type="smallint")
* @ORM\DiscriminatorMap({
@ -91,7 +95,6 @@ abstract class AbstractParameter extends AbstractNamedDBElement
/**
* @var string The unit in which the value values are given (e.g. V)
* @Assert\Length(max=5)
* @ORM\Column(type="string", nullable=false)
*/
protected string $unit = '';

View file

@ -33,7 +33,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class AttachmentType.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\CategoryRepository")
* @ORM\Table(name="`categories`")
* @ORM\Table(name="`categories`", indexes={
* @ORM\Index(name="category_idx_name", columns={"name"}),
* @ORM\Index(name="category_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Category extends AbstractPartsContainingDBElement
{

View file

@ -61,7 +61,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class Footprint.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\FootprintRepository")
* @ORM\Table("`footprints`")
* @ORM\Table("`footprints`", indexes={
* @ORM\Index(name="footprint_idx_name", columns={"name"}),
* @ORM\Index(name="footprint_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Footprint extends AbstractPartsContainingDBElement
{

View file

@ -61,7 +61,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class Manufacturer.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\ManufacturerRepository")
* @ORM\Table("`manufacturers`")
* @ORM\Table("`manufacturers`", indexes={
* @ORM\Index(name="manufacturer_name", columns={"name"}),
* @ORM\Index(name="manufacturer_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Manufacturer extends AbstractCompany
{

View file

@ -55,7 +55,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* This could be something like N, grams, meters, etc...
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\MeasurementUnitRepository")
* @ORM\Table(name="`measurement_units`")
* @ORM\Table(name="`measurement_units`", indexes={
* @ORM\Index(name="unit_idx_name", columns={"name"}),
* @ORM\Index(name="unit_idx_parent_name", columns={"parent_id", "name"}),
* })
* @UniqueEntity("unit")
*/
class MeasurementUnit extends AbstractPartsContainingDBElement
@ -66,7 +69,7 @@ class MeasurementUnit extends AbstractPartsContainingDBElement
* @ORM\Column(type="string", name="unit", nullable=true)
* @Assert\Length(max=10)
*/
protected string $unit;
protected ?string $unit = null;
/**
* @var bool Determines if the amount value associated with this unit should be treated as integer.

View file

@ -74,7 +74,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Otherwise this class would be too big, to be maintained.
*
* @ORM\Entity(repositoryClass="App\Repository\PartRepository")
* @ORM\Table("`parts`")
* @ORM\Table("`parts`", indexes={
* @ORM\Index(name="parts_idx_datet_name_last_id_needs", columns={"datetime_added", "name", "last_modified", "id", "needs_review"}),
* @ORM\Index(name="parts_idx_name", columns={"name"}),
* })
*/
class Part extends AttachmentContainingDBElement
{

View file

@ -58,7 +58,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* It is the connection between a part and its store locations.
*
* @ORM\Entity()
* @ORM\Table(name="part_lots")
* @ORM\Table(name="part_lots", indexes={
* @ORM\Index(name="part_lots_idx_instock_un_expiration_id_part", columns={"instock_unknown", "expiration_date", "id_part"}),
* @ORM\Index(name="part_lots_idx_needs_refill", columns={"needs_refill"}),
* })
* @ORM\HasLifecycleCallbacks()
* @ValidPartLot()
*/

View file

@ -79,7 +79,7 @@ trait ManufacturerTrait
* @ORM\Column(type="string", length=255, nullable=true)
* @Assert\Choice({"announced", "active", "nrfnd", "eol", "discontinued", ""})
*/
protected string $manufacturing_status = '';
protected ?string $manufacturing_status = '';
/**
* Get the link to the website of the article on the manufacturers website

View file

@ -79,7 +79,7 @@ trait OrderTrait
* @ORM\OneToOne(targetEntity="App\Entity\PriceInformations\Orderdetail")
* @ORM\JoinColumn(name="order_orderdetails_id", referencedColumnName="id")
*/
protected Orderdetail $order_orderdetail;
protected ?Orderdetail $order_orderdetail = null;
/**
* Get the selected order orderdetails of this part.

View file

@ -61,7 +61,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class Store location.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\StorelocationRepository")
* @ORM\Table("`storelocations`")
* @ORM\Table("`storelocations`", indexes={
* @ORM\Index(name="location_idx_name", columns={"name"}),
* @ORM\Index(name="location_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Storelocation extends AbstractPartsContainingDBElement
{

View file

@ -65,7 +65,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class Supplier.
*
* @ORM\Entity(repositoryClass="App\Repository\Parts\SupplierRepository")
* @ORM\Table("`suppliers`")
* @ORM\Table("`suppliers`", indexes={
* @ORM\Index(name="supplier_idx_name", columns={"name"}),
* @ORM\Index(name="supplier_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Supplier extends AbstractCompany
{

View file

@ -59,7 +59,10 @@ use Symfony\Component\Validator\Constraints as Assert;
*
* @UniqueEntity("iso_code")
* @ORM\Entity()
* @ORM\Table(name="currencies")
* @ORM\Table(name="currencies", indexes={
* @ORM\Index(name="currency_idx_name", columns={"name"}),
* @ORM\Index(name="currency_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Currency extends AbstractStructuralDBElement
{
@ -78,7 +81,7 @@ class Currency extends AbstractStructuralDBElement
* @ORM\Column(type="string")
* @Assert\Currency()
*/
protected string $iso_code;
protected string $iso_code = "";
/**
* @ORM\OneToMany(targetEntity="Currency", mappedBy="parent", cascade={"persist"})

View file

@ -66,7 +66,9 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* Class Orderdetail.
*
* @ORM\Table("`orderdetails`")
* @ORM\Table("`orderdetails`", indexes={
* @ORM\Index(name="orderdetails_supplier_part_nr", columns={"supplierpartnr"}),
* })
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks()
* @UniqueEntity({"supplierpartnr", "supplier", "part"})

View file

@ -66,7 +66,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* Class Pricedetail.
*
* @ORM\Entity()
* @ORM\Table("`pricedetails`")
* @ORM\Table("`pricedetails`", indexes={
* @ORM\Index(name="pricedetails_idx_min_discount", columns={"min_discount_quantity"}),
* @ORM\Index(name="pricedetails_idx_min_discount_price_qty", columns={"min_discount_quantity", "price_related_quantity"}),
* })
* @ORM\HasLifecycleCallbacks()
* @UniqueEntity(fields={"min_discount_quantity", "orderdetail"})
*/

View file

@ -56,7 +56,10 @@ use Symfony\Component\Validator\Constraints as Assert;
* This entity represents an user group.
*
* @ORM\Entity()
* @ORM\Table("`groups`")
* @ORM\Table("`groups`", indexes={
* @ORM\Index(name="group_idx_name", columns={"name"}),
* @ORM\Index(name="group_idx_parent_name", columns={"parent_id", "name"}),
* })
*/
class Group extends AbstractStructuralDBElement implements HasPermissionsInterface
{
@ -75,8 +78,9 @@ class Group extends AbstractStructuralDBElement implements HasPermissionsInterfa
/**
* @ORM\OneToMany(targetEntity="User", mappedBy="group")
* @var Collection<User>
*/
protected Collection $users;
protected $users;
/**
* @var bool If true all users associated with this group must have enabled some kind of 2 factor authentication
@ -85,7 +89,7 @@ class Group extends AbstractStructuralDBElement implements HasPermissionsInterfa
protected $enforce2FA = false;
/**
* @var Collection<int, GroupAttachment>
* @ORM\OneToMany(targetEntity="App\Entity\Attachments\ManufacturerAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OneToMany(targetEntity="App\Entity\Attachments\GroupAttachment", mappedBy="element", cascade={"persist", "remove"}, orphanRemoval=true)
* @ORM\OrderBy({"name" = "ASC"})
* @Assert\Valid()
*/

View file

@ -44,8 +44,7 @@ namespace App\Entity\UserSystem;
use App\Entity\Base\TimestampTrait;
use Doctrine\ORM\Mapping as ORM;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorKeyInterface;
use u2flib_server\Registration;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
/**
* @ORM\Entity
@ -56,7 +55,7 @@ use u2flib_server\Registration;
* })
* @ORM\HasLifecycleCallbacks()
*/
class U2FKey implements TwoFactorKeyInterface
class U2FKey implements LegacyU2FKeyInterface
{
use TimestampTrait;
@ -110,14 +109,6 @@ class U2FKey implements TwoFactorKeyInterface
**/
protected ?User $user = null;
public function fromRegistrationData(Registration $data): void
{
$this->keyHandle = $data->keyHandle;
$this->publicKey = $data->publicKey;
$this->certificate = $data->certificate;
$this->counter = $data->counter;
}
public function getKeyHandle(): string
{
return $this->keyHandle;

View file

@ -57,7 +57,9 @@ use App\Entity\PriceInformations\Currency;
use App\Security\Interfaces\HasPermissionsInterface;
use App\Validator\Constraints\Selectable;
use App\Validator\Constraints\ValidPermission;
use Jbtronics\TFAWebauthn\Model\LegacyU2FKeyInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Webauthn\PublicKeyCredentialUserEntity;
use function count;
use DateTime;
use Doctrine\Common\Collections\ArrayCollection;
@ -65,8 +67,6 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use function in_array;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorInterface as U2FTwoFactorInterface;
use R\U2FTwoFactorBundle\Model\U2F\TwoFactorKeyInterface;
use Scheb\TwoFactorBundle\Model\BackupCodeInterface;
use Scheb\TwoFactorBundle\Model\Google\TwoFactorInterface;
use Scheb\TwoFactorBundle\Model\PreferredProviderInterface;
@ -74,17 +74,20 @@ use Scheb\TwoFactorBundle\Model\TrustedDeviceInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface;
/**
* This entity represents a user, which can log in and have permissions.
* Also this entity is able to save some informations about the user, like the names, email-address and other info.
*
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ORM\Table("`users`")
* @ORM\Table("`users`", indexes={
* @ORM\Index(name="user_idx_username", columns={"name"})
* })
* @ORM\EntityListeners({"App\EntityListeners\TreeCacheInvalidationListener"})
* @UniqueEntity("name", message="validator.user.username_already_used")
*/
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, U2FTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
class User extends AttachmentContainingDBElement implements UserInterface, HasPermissionsInterface, TwoFactorInterface, BackupCodeInterface, TrustedDeviceInterface, WebauthnTwoFactorInterface, PreferredProviderInterface, PasswordAuthenticatedUserInterface
{
//use MasterAttachmentTrait;
@ -146,7 +149,8 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
/**
* @var Group|null the group this user belongs to
* @ORM\ManyToOne(targetEntity="Group", inversedBy="users", fetch="EAGER")
* DO NOT PUT A fetch eager here! Otherwise you can not unset the group of a user! This seems to be some kind of bug in doctrine. Maybe this is fixed in future versions.
* @ORM\ManyToOne(targetEntity="Group", inversedBy="users")
* @ORM\JoinColumn(name="group_id", referencedColumnName="id")
* @Selectable()
*/
@ -239,11 +243,17 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*/
protected ?DateTime $backupCodesGenerationDate = null;
/** @var Collection<int, TwoFactorKeyInterface>
/** @var Collection<int, LegacyU2FKeyInterface>
* @ORM\OneToMany(targetEntity="App\Entity\UserSystem\U2FKey", mappedBy="user", cascade={"REMOVE"}, orphanRemoval=true)
*/
protected $u2fKeys;
/**
* @var Collection<int, WebauthnKey>
* @ORM\OneToMany(targetEntity="App\Entity\UserSystem\WebauthnKey", mappedBy="user", cascade={"REMOVE"}, orphanRemoval=true)
*/
protected $webauthn_keys;
/**
* @var Currency|null The currency the user wants to see prices in.
* Dont use fetch=EAGER here, this will cause problems with setting the currency setting.
@ -272,6 +282,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
parent::__construct();
$this->permissions = new PermissionsEmbed();
$this->u2fKeys = new ArrayCollection();
$this->webauthn_keys = new ArrayCollection();
}
/**
@ -762,7 +773,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*/
public function isBackupCode(string $code): bool
{
return in_array($code, $this->backupCodes, true);
return in_array($code, $this->getBackupCodes(), true);
}
/**
@ -772,7 +783,7 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
*/
public function invalidateBackupCode(string $code): void
{
$key = array_search($code, $this->backupCodes, true);
$key = array_search($code, $this->getBackupCodes(), true);
if (false !== $key) {
unset($this->backupCodes[$key]);
}
@ -836,48 +847,48 @@ class User extends AttachmentContainingDBElement implements UserInterface, HasPe
++$this->trustedDeviceCookieVersion;
}
/**
* Check if U2F is enabled.
*/
public function isU2FAuthEnabled(): bool
{
return count($this->u2fKeys) > 0;
}
/**
* Get all U2F Keys that are associated with this user.
*
* @psalm-return Collection<int, TwoFactorKeyInterface>
*/
public function getU2FKeys(): Collection
{
return $this->u2fKeys;
}
/**
* Add a U2F key to this user.
*/
public function addU2FKey(TwoFactorKeyInterface $key): void
{
$this->u2fKeys->add($key);
}
/**
* Remove a U2F key from this user.
*/
public function removeU2FKey(TwoFactorKeyInterface $key): void
{
$this->u2fKeys->removeElement($key);
}
public function getPreferredTwoFactorProvider(): ?string
{
//If U2F is available then prefer it
if ($this->isU2FAuthEnabled()) {
return 'u2f_two_factor';
//if ($this->isU2FAuthEnabled()) {
// return 'u2f_two_factor';
//}
if ($this->isWebAuthnAuthenticatorEnabled()) {
return 'webauthn_two_factor_provider';
}
//Otherwise use other methods
return null;
}
public function isWebAuthnAuthenticatorEnabled(): bool
{
return count($this->u2fKeys) > 0
|| count($this->webauthn_keys) > 0;
}
public function getLegacyU2FKeys(): iterable
{
return $this->u2fKeys;
}
public function getWebAuthnUser(): PublicKeyCredentialUserEntity
{
return new PublicKeyCredentialUserEntity(
$this->getUsername(),
(string) $this->getId(),
$this->getFullName(),
);
}
public function getWebauthnKeys(): iterable
{
return $this->webauthn_keys;
}
public function addWebauthnKey(WebauthnKey $webauthnKey): void
{
$this->webauthn_keys->add($webauthnKey);
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Entity\UserSystem;
use App\Entity\Base\TimestampTrait;
use Doctrine\ORM\Mapping as ORM;
use Webauthn\PublicKeyCredentialSource as BasePublicKeyCredentialSource;
/**
* @ORM\Table(name="webauthn_keys")
* @ORM\Entity()
* @ORM\HasLifecycleCallbacks()
*/
class WebauthnKey extends BasePublicKeyCredentialSource
{
use TimestampTrait;
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected int $id;
/**
* @ORM\Column(type="string")
*/
protected string $name;
/**
* @ORM\ManyToOne(targetEntity="App\Entity\UserSystem\User", inversedBy="webauthn_keys")
**/
protected ?User $user = null;
/**
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param string $name
* @return WebauthnKey
*/
public function setName(string $name): WebauthnKey
{
$this->name = $name;
return $this;
}
/**
* @return User|null
*/
public function getUser(): ?User
{
return $this->user;
}
/**
* @param User|null $user
* @return WebauthnKey
*/
public function setUser(?User $user): WebauthnKey
{
$this->user = $user;
return $this;
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
public static function fromRegistration(BasePublicKeyCredentialSource $registration): self
{
return new self(
$registration->getPublicKeyCredentialId(),
$registration->getType(),
$registration->getTransports(),
$registration->getAttestationType(),
$registration->getTrustPath(),
$registration->getAaguid(),
$registration->getCredentialPublicKey(),
$registration->getUserHandle(),
$registration->getCounter(),
$registration->getOtherUI()
);
}
}

View file

@ -75,6 +75,11 @@ class TreeCacheInvalidationListener
if ($element instanceof AbstractStructuralDBElement || $element instanceof LabelProfile) {
$secure_class_name = str_replace('\\', '_', get_class($element));
$this->cache->invalidateTags([$secure_class_name]);
//Trigger a sidebar reload for all users (see SidebarTreeUpdater service)
if(!$element instanceof LabelProfile) {
$this->cache->invalidateTags(['sidebar_tree_update']);
}
}
//If a user change, then invalidate all cached trees for him

View file

@ -146,7 +146,7 @@ final class PasswordChangeNeededSubscriber implements EventSubscriberInterface
*/
public static function TFARedirectNeeded(User $user): bool
{
$tfa_enabled = $user->isU2FAuthEnabled() || $user->isGoogleAuthenticatorEnabled();
$tfa_enabled = $user->isWebAuthnAuthenticatorEnabled() || $user->isGoogleAuthenticatorEnabled();
return null !== $user->getGroup() && $user->getGroup()->isEnforce2FA() && !$tfa_enabled;
}

View file

@ -1,122 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
*/
namespace App\EventSubscriber\UserSystem;
use App\Entity\UserSystem\U2FKey;
use App\Entity\UserSystem\User;
use App\Events\SecurityEvent;
use App\Events\SecurityEvents;
use Doctrine\ORM\EntityManagerInterface;
use R\U2FTwoFactorBundle\Event\RegisterEvent;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* This subscriber is used to write U2F keys to DB, after user added them via GUI.
*/
final class RegisterU2FSubscriber implements EventSubscriberInterface
{
private EntityManagerInterface $em;
private bool $demo_mode;
private FlashBagInterface $flashBag;
private UrlGeneratorInterface $router;
/**
* @var EventDispatcher
*/
private EventDispatcherInterface $eventDispatcher;
public function __construct(UrlGeneratorInterface $router, EntityManagerInterface $entityManager, SessionInterface $session, EventDispatcherInterface $eventDispatcher, bool $demo_mode)
{
/** @var Session $session */
$this->router = $router;
$this->em = $entityManager;
$this->demo_mode = $demo_mode;
$this->flashBag = $session->getFlashBag();
$this->eventDispatcher = $eventDispatcher;
}
public static function getSubscribedEvents(): array
{
return [
'r_u2f_two_factor.register' => 'onRegister',
];
}
public function onRegister(RegisterEvent $event): void
{
//Skip adding of U2F key on demo mode
if (!$this->demo_mode) {
$user = $event->getUser();
if (!$user instanceof User) {
throw new \InvalidArgumentException('Only User objects can be registered for U2F!');
}
$registration = $event->getRegistration();
$newKey = new U2FKey();
$newKey->fromRegistrationData($registration);
$newKey->setUser($user);
$newKey->setName($event->getKeyName());
// persist the new key
$this->em->persist($newKey);
$this->em->flush();
$this->flashBag->add('success', 'tfa_u2f.key_added_successful');
$security_event = new SecurityEvent($user);
$this->eventDispatcher->dispatch($security_event, SecurityEvents::U2F_ADDED);
}
// generate new response, here we redirect the user to the fos user
// profile
$response = new RedirectResponse($this->router->generate('user_settings'));
$event->setResponse($response);
}
}

View file

@ -106,6 +106,7 @@ class LabelOptionsType extends AbstractType
'mode' => 'html-label',
'attr' => [
'rows' => 4,
'data-ck-class' => 'ck-html-label'
],
]);

View file

@ -253,7 +253,7 @@ class StructuralEntityType extends AbstractType
$html .= $this->getElementNameWithLevelWhitespace($choice, $options, '<span class="picker-level"></span>');
if ($options['show_fullpath_in_subtext'] && null !== $choice->getParent()) {
$html .= '<span class="ms-3 badge rounded-pill bg-secondary float-end"><i class="fa-solid fa-folder-tree"></i>&nbsp;' . trim(htmlspecialchars($choice->getParent()->getFullPath())) . '</span>';
$html .= '<span class="ms-3 badge rounded-pill bg-secondary float-end picker-us"><i class="fa-solid fa-folder-tree"></i>&nbsp;' . trim(htmlspecialchars($choice->getParent()->getFullPath())) . '</span>';
}
if ($choice instanceof AttachmentType && !empty($choice->getFiletypeFilter())) {

View file

@ -45,6 +45,23 @@ abstract class AbstractPartsContainingRepository extends StructuralDBElementRepo
*/
abstract public function getPartsCount(object $element): int;
/**
* Returns the count of the parts associated with this element and all its children.
* Please be aware that this function is pretty slow on large trees!
* @param AbstractPartsContainingDBElement $element
* @return int
*/
public function getPartsCountRecursive(AbstractPartsContainingDBElement $element): int
{
$count = $this->getPartsCount($element);
foreach ($element->getChildren() as $child) {
$count += $this->getPartsCountRecursive($child);
}
return $count;
}
protected function getPartsByField(object $element, array $order_by, string $field_name): array
{
if (!$element instanceof AbstractPartsContainingDBElement) {

View file

@ -27,16 +27,50 @@ use App\Entity\LabelSystem\LabelOptions;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
use Com\Tecnick\Barcode\Barcode;
use InvalidArgumentException;
use PhpParser\Node\Stmt\Label;
use Symfony\Component\Mime\MimeTypes;
use Twig\Extra\Html\HtmlExtension;
final class BarcodeGenerator
{
private BarcodeContentGenerator $barcodeContentGenerator;
public function __construct(BarcodeContentGenerator $barcodeContentGenerator)
{
$this->barcodeContentGenerator = $barcodeContentGenerator;
}
public function generateHTMLBarcode(LabelOptions $options, object $target): ?string
{
$svg = $this->generateSVG($options, $target);
$base64 = $this->dataUri($svg, 'image/svg+xml');
return '<img src="'.$base64.'" width="100%" style="min-height: 25px;" alt="'. $this->getContent($options, $target) . '" />';
}
/**
* Creates a data URI (RFC 2397).
* Based on the Twig implementaion from HTMLExtension
*
* Length validation is not performed on purpose, validation should
* be done before calling this filter.
*
* @return string The generated data URI
*/
private function dataUri(string $data, string $mime): string
{
$repr = 'data:';
$repr .= $mime;
if (0 === strpos($mime, 'text/')) {
$repr .= ','.rawurlencode($data);
} else {
$repr .= ';base64,'.base64_encode($data);
}
return $repr;
}
public function generateSVG(LabelOptions $options, object $target): ?string
{
$barcode = new Barcode();

View file

@ -71,7 +71,7 @@ final class LabelTextReplacer
public function replace(string $lines, object $target): string
{
$patterns = [
'/(\[\[[A-Z_]+\]\])/' => function ($match) use ($target) {
'/(\[\[[A-Z_0-9]+\]\])/' => function ($match) use ($target) {
return $this->handlePlaceholder($match[0], $target);
},
];

View file

@ -0,0 +1,59 @@
<?php
namespace App\Services\LabelSystem\PlaceholderProviders;
use App\Entity\LabelSystem\LabelOptions;
use App\Entity\LabelSystem\LabelProfile;
use App\Services\LabelSystem\BarcodeGenerator;
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
final class BarcodeProvider implements PlaceholderProviderInterface
{
private BarcodeGenerator $barcodeGenerator;
private BarcodeContentGenerator $barcodeContentGenerator;
public function __construct(BarcodeGenerator $barcodeGenerator, BarcodeContentGenerator $barcodeContentGenerator)
{
$this->barcodeGenerator = $barcodeGenerator;
$this->barcodeContentGenerator = $barcodeContentGenerator;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
{
if ('[[1D_CONTENT]]' === $placeholder) {
try {
return $this->barcodeContentGenerator->get1DBarcodeContent($label_target);
} catch (\InvalidArgumentException $e) {
return 'ERROR!';
}
}
if ('[[2D_CONTENT]]' === $placeholder) {
try {
return $this->barcodeContentGenerator->getURLContent($label_target);
} catch (\InvalidArgumentException $e) {
return 'ERROR!';
}
}
if ('[[BARCODE_QR]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType('qr');
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C39]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType('code39');
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
if ('[[BARCODE_C128]]' === $placeholder) {
$label_options = new LabelOptions();
$label_options->setBarcodeType('code128');
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
}
return null;
}
}

View file

@ -27,6 +27,8 @@ use App\Entity\UserSystem\User;
use DateTime;
use IntlDateFormatter;
use Locale;
use Symfony\Component\Routing\Generator\UrlGenerator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
/**
@ -36,11 +38,13 @@ final class GlobalProviders implements PlaceholderProviderInterface
{
private string $partdb_title;
private Security $security;
private UrlGeneratorInterface $url_generator;
public function __construct(string $partdb_title, Security $security)
public function __construct(string $partdb_title, Security $security, UrlGeneratorInterface $url_generator)
{
$this->partdb_title = $partdb_title;
$this->security = $security;
$this->url_generator = $url_generator;
}
public function replace(string $placeholder, object $label_target, array $options = []): ?string
@ -101,6 +105,10 @@ final class GlobalProviders implements PlaceholderProviderInterface
return $formatter->format($now);
}
if ('[[INSTANCE_URL]]' === $placeholder) {
return $this->url_generator->generate('homepage', [], UrlGenerator::ABSOLUTE_URL);
}
return null;
}
}

View file

@ -39,6 +39,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\MappingException;
use DoctrineExtensions\Query\Mysql\Date;
use Exception;
use InvalidArgumentException;
use ReflectionClass;
@ -171,6 +172,22 @@ class TimeTravel
}
}
/**
* This function decodes the array which is created during the json_encode of a datetime object and returns a DateTime object.
* @param array $input
* @return DateTime
* @throws Exception
*/
private function dateTimeDecode(?array $input): ?\DateTime
{
//Allow null values
if ($input === null) {
return null;
}
return new \DateTime($input['date'], new \DateTimeZone($input['timezone']));
}
/**
* Apply the changeset in the given LogEntry to the element.
*
@ -195,6 +212,10 @@ class TimeTravel
$data = BigDecimal::of($data);
}
if (!$data instanceof DateTime && ('datetime' === $metadata->getFieldMapping($field)['type'])) {
$data = $this->dateTimeDecode($data);
}
$this->setField($element, $field, $data);
}
if ($metadata->hasAssociation($field)) {

View file

@ -76,6 +76,10 @@ class BackupCodeManager
return;
}
if ($user->isWebAuthnAuthenticatorEnabled()) {
return;
}
$user->setBackupCodes([]);
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Services\Trees;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class SidebarTreeUpdater
{
private const CACHE_KEY = 'sidebar_tree_updated';
private const TTL = 60 * 60 * 24; // 24 hours
private CacheInterface $cache;
public function __construct(TagAwareCacheInterface $treeCache)
{
$this->cache = $treeCache;
}
/**
* Returns the time when the sidebar tree was updated the last time.
* The frontend uses this information to reload the sidebar tree.
* @return \DateTimeInterface
*/
public function getLastTreeUpdate(): \DateTimeInterface
{
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item) {
$item->expiresAfter(self::TTL);
//This tag and therfore this whole cache gets cleared by TreeCacheInvalidationListener when a structural element is changed
$item->tag('sidebar_tree_update');
return new \DateTime();
});
}
}

View file

@ -73,13 +73,9 @@ class UserCacheKeyGenerator
$user = $this->security->getUser();
}
if (!$user instanceof User){
throw new \RuntimeException('UserCacheKeyGenerator::generateKey() was called without a user, but no user is logged in!');
}
//If the user is null, then treat it as anonymous user.
//When the anonymous user is passed as user then use this path too.
if (null === $user || User::ID_ANONYMOUS === $user->getID()) {
if (null === $user || !($user instanceof User) || User::ID_ANONYMOUS === $user->getID()) {
return 'user$_'.User::ID_ANONYMOUS;
}