Gestion de la sécurité

et des permissions

avec Symfony

@marion_age

Société coopérative fondée en 2011

Auto-gérée et détenue à 100% par les 60+ personnes qui y travaillent

1 personne = 1 voix

      
    contact@les-tilleuls.coop / jobs@les-tilleuls.coop

Stand n°5

Marion AGÉ

Directrice Technique & co-gérante @ Les-Tilleuls.coop

Symfony / API Platform & Vue.js

 

    @marion_age

La sécurité

ça fait peur

Gestion de session

# config/packages/framework.yaml
framework:
    session:
        handler_id: null
        cookie_secure: auto
        cookie_samesite: lax
        storage_factory_id: session.storage.factory.native

Authentication

 

Authorization

Symfony

SecurityBundle

https://github.com/symfony/security-bundle/

Authentication

Navigation anonyme

Source : symfony.com

Une requête arrive

⬇️

Aucune session n'est connue

⬇️

La requête ne possède pas d'informations de connexion

⬇️

L'utilisateur n'est pas authentifié
Il n'aura pas accès aux ressources protégées.

401

Navigation anonyme

Authentication

Authentication

Une requête arrive

⬇️

La requête possède des informations de connexion

⬇️

Les identifiants correspondent à un utilisateur du système ✔️

⬇️

Vérifications supplémentaires

⬇️

Création de la session

⬇️

Reconnaissance de l'utilisateur lors de la navigation à venir ✔️

Composant Security

$ composer req security maker-bundle orm-pack

$ bin/console make:user

$ bin/console make:entity User

# config/packages/security.yaml
# src/Entity/User.php
# src/Repository/UserRepository.php
interface UserInterface
{
    public function getRoles(): array;

    public function eraseCredentials();

    public function getUserIdentifier(): string;
}

interface PasswordAuthenticatedUserInterface
{
    public function getPassword(): ?string;
}

interface UserLoaderInterface
{
    public function loadUserByIdentifier(string $identifier): ?UserInterface;
}

Configuration

# config/packages/security.yaml

security:
  enable_authenticator_manager: true
    
  password_hashers:
    Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
    App\Entity\User:
      algorithm: auto

  providers:
    app_user_provider:
      entity:
        class: App\Entity\User
        property: email

SHA TLS RSA WTF

Grégoire Hébert

https://speakerdeck.com/gregoirehebert/tls-rsa-aes-wtf

 

Symfony PasswordHasher Component

Robin Chalas @ SymfonyWorld Summer 2021

https://slides.com/chalasr/symfony-password-hasher

interface UserProviderInterface
{
    public function refreshUser(UserInterface $user);

    public function supportsClass(string $class);

    public function loadUserByIdentifier(string $identifier): UserInterface;
}
# config/packages/security.yaml

security:
  # ...    
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      lazy: true
      provider: app_user_provider
      form_login:
        login_path: login
        check_path: login
        enable_csrf: true
      logout:
        path: logout
  
  # ...

Configuration

Configuration

La requête passe dans un firewall (ex : main)

⬇️

Le service AuthenticatorManager envoie la requête à l'Authenticator

⬇️

L'Authenticator extrait les informations de connexion

⬇️

L'Authenticator crée et retourne un Security Passport

⬇️

L'AuthenticatorManager vérifie les badges, envoie les événements et enregistre le token de session

⬇️

La réponse est retournée.

Passport

Le Passport est un objet qui contient :

  • le UserBadge
  • PasswordCredentials
  • Les éventuels Badges annexés
    CsrfTokenBadge, LdapBadge, RememberMeBadge...

Événements

Source : symfony.com

  • L'Authenticator transforme la requête en Passport + Badges transmis aux listeners
  • Les listeners se chargent de toute la logique métier en vérifiant le Passport et chacun de ses badges
  • De multiples événements permettent d'intervenir dans le processus si besoin
interface UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user);

    public function checkPostAuth(UserInterface $user);
}

Authenticators

  • Form Login : formulaire de login
  • JSON Login : token API
  • HTTP Basic : identifiant + mot de passe via dialog
  • Login Link : connexion sans mot de passe "magic links"
  • X.509 Client Certificates : connexion grâce à un certificat
  • Remote users : pré-auth du user par un module du serveur (ex : Kerberos)

Authenticators

  • LexikJWTAuthenticationBundle
  • OneloginSamlBundle
  • WebauthnSymfonyBundle
  • KnpUOAuth2ClientBundle
  • SchebTwoFactorBundle
  • custom_authenticator
interface AuthenticatorInterface
{
  public function supports(Request $request): ?bool;
    
  
  public function authenticate(Request $request): Passport;
    
  
  public function createToken(Passport $passport, string $firewallName): TokenInterface;
    
  
  public function onAuthenticationSuccess(Request $request, 
                                          TokenInterface $token,
                                          string $firewallName): ?Response;
    
  
  public function onAuthenticationFailure(Request $request, 
                                          AuthenticationException $exception): ?Response;
}

Récap'

  • Authenticator : intercepte les requêtes et authentifie les users avec un système de passeport + badges
  • Passport (et ses Badges) passe dans différents listeners qui se chargent de la logique, maintenu par Symfony
  • Provider récupère l'utilisateur dans le stockage à partir de l'identifiant
  • User Checker valide l'utilisateur avant/après une authentification
  • Entry Point redirige un utilisateur non authentifié
  • Access Denied Handler gère le retour en cas d'erreur

Authorization

Une requête arrive

⬇️

Une session est reconnue

⬇️

L'utilisateur ne possède pas les droits

pour accéder à la ressource demandée

⬇️

L'accès est refusé

403

Accès refusé

Accès autorisé

Une requête arrive

⬇️

Une session est reconnue

⬇️

L'utilisateur possède les droits requis

pour accéder à la ressource demandée

⬇️

L'accès est autorisé

⬇️

Les données sont retournées dans la réponse

Le service Security

Donne accès au token courant et son utilisateur

// dans un controller
$this->getUser();

// dans un service
$this->security->getUser();

// dans twig
{{ app.user.firstname }}
namespace Symfony\Component\Security\Core;

class Security implements AuthorizationCheckerInterface
{
    public function getUser(): ?UserInterface {}
    
    public function isGranted(mixed $attributes, mixed $subject = null): bool {}

    public function getToken(): ?TokenInterface {}
}

Les Voters

Centralisent la logique d’authorization pour répondre à

“est-ce que l’utilisateur a le droit de faire ça sur cette ressource ?”

$this->isGranted('ARTICLE_MANAGE', $article)

Services Symfony avec le tag security.voter

abstract class Voter implements VoterInterface, CacheableVoterInterface
{
    public function vote(TokenInterface $token, mixed $subject, array $attributes): int
    {
    }

    public function supportsAttribute(string $attribute): bool
    {
    }

    public function supportsType(string $subjectType): bool
    {
    }

    abstract protected function supports(string $attribute, mixed $subject): bool;

    abstract protected function voteOnAttribute(string $attribute, 
                                                mixed $subject, 
                                                TokenInterface $token): bool;
}

Les Voters

  • RoleVoter (reconnait ROLE_)
  • AuthenticatedVoter (reconnait IS_AUTHENTICATED_)
  • make:voter
class ArticleVoter extends Voter
{
    protected function supports(string $attribute, $subject): bool
    {
        return 'ARTICLE_MANAGE' === $attribute && $subject instanceof Article;
    }

    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof UserInterface || !$subject instanceof Article) {
            return false;
        }

        return in_array('ROLE_ADMIN', $user->getRoles(), true) ||
            !$subject->getAuthor() ||
            $user === $subject->getAuthor();
    }
}

AccessDecisionManager

  • affirmative (défaut)
  • consensus
  • unanimous
  • priority

Les Rôles

# config/packages/security.yaml
security:
    # ...

    role_hierarchy:
        ROLE_ADMIN:       ROLE_USER
        ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
        
    # ...
    
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }

access_control

# config/packages/security.yaml
security:
    # ...
    
    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/profile, roles: ROLE_USER }
        - { path: ^/login, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

Dans un Controller

#IsGranted('ROLE_EDITOR') 
class ArticleController extends AbstractController
{
    #IsGranted('ARTICLE_MANAGE', subject: 'article') 
    public function manageArticle(Article $article)
    {
        // $this->denyAccessUnlessGranted('ARTICLE_MANAGE', $article);
    }
}

Dans Twig

is_granted('ROLE_ADMIN')

Expression Language

# Prix promo si
user.getGroup() in ['good_customers', 'collaborator']

# Mise en avant d'un article sur la page d'accueil
article.commentCount > 100 and article.category not in ["misc"]
class PermissionExpressionLanguageProvider implements ExpressionFunctionProviderInterface
{
  private const BEGIN_DATE = '2022-04-07';
  private const END_DATE = '2022-04-08';

  public function getFunctions(): array
  {
    return [
      new ExpressionFunction('is_sflive', function () {
        return sprintf(
          'new \DateTime() >= new \DateTime(%s) and new \DateTime() <= new \DateTime(%s)',
          self::BEGIN_DATE, self::END_DATE
        );
      }, function () {
        $today = new \DateTime();

        return $today >= new \DateTime(self::BEGIN_DATE) &&
          $today <= new \DateTime(self::END_DATE);
      }),
    ];
  }
}

Un système de permissions configurables

#[ORM\Entity(repositoryClass: RuleRepository::class)]
class Rule
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 255)]
    private string $slug;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $resource = null;

    #[ORM\OneToMany(mappedBy: 'rule', targetEntity: Permission::class, orphanRemoval: true)]
    private Collection $permissions;
}
#[ORM\Entity(repositoryClass: PermissionRepository::class)]
class Permission
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\ManyToOne(inversedBy: 'permissions')]
    #[ORM\JoinColumn(nullable: false)]
    private Rule $rule;

    #[ORM\ManyToOne(inversedBy: 'permissions')]
    private ?Group $group = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $extra = null;
}
#[ORM\Entity(repositoryClass: GroupRepository::class)]
#[ORM\Table(name: '`group`')]
class Group
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 255)]
    private string $name;

    #[ORM\OneToMany(mappedBy: 'group', targetEntity: Permission::class)]
    private Collection $permissions;

    #[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'groups')]
    private Collection $users;
}
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    // ...
    #[ORM\ManyToMany(targetEntity: Group::class, inversedBy: 'users')]
    private Collection $groups;
}
Slug Resource
ADDRESS_SHOW App\Entity\User
ARTICLE_MANAGE App\Entity\Article

Rules

Rule Group Extra
ADDRESS_SHOW RH
ADDRESS_SHOW object == user
ARTICLE_MANAGE Modérateurs
ARTICLE_MANAGE Externes sf_live()

Permissions

final class PermissionEngine
{
    public function __construct(
        private UserRepository $repos,
    ) {}

    public function can(User $user, Rule $rule, object $subject): bool
    {
        $expr = new ExpressionLanguage(null, [
            new PermissionExpressionLanguageProvider(),
        ]);
        $vars = ['object' => $subject, 'user' => $user];

        $permissions = $rule->getPermissions();
        foreach ($permissions as $permission) {
            if (
                ((null === $group = $permission->getGroup()) || $user->hasGroup($group)) &&
                ((null === $extra = $permission->getExtra()) || $expr->evaluate($extra, $vars))
            ) {
                return true;
            }
        }

        return false;
    }
}
class PermissionVoter extends Voter
{
   private ?Rule $rule = null;

   public function __construct(
      private RuleRepository $ruleRepos,
      private PermissionEngine $permissionEngine
   ) {}

   protected function supports(string $attribute, $subject): bool
   {
      $this->rule = $this->ruleRepos->findOneBy(['slug' => $attribute]);

      return null !== $this->rule;
   }

   protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
   {
      $user = $token->getUser();
      if (!$user instanceof User || null === $this->rule) {
         return false;
      }

      return $this->permissionEngine->can($user, $this->rule, $subject);
   }
}
#[ApiResource(
    itemOperations: [
        'GET' => [
            'security' => "is_granted('ARTICLE_MANAGE', object)",
        ],
    ],
)]
class Article {}


class User {
    #[ApiProperty(security: "is_granted('ADDRESS_SHOW', object)")]
    private ?string $address = null;
}

API Platform

Des alertes basées sur les permissions

#[ORM\Entity(repositoryClass: AlertRepository::class)]
class Alert
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id;

    #[ORM\Column(length: 255)]
    private string $label;

    #[ORM\Column(length: 255)]
    private string $eventName;

    #[ORM\ManyToOne]
    private ?Rule $rule = null;
}
class ArticleCreatedEvent extends Event implements AlertableEventInterface
{
    public const NAME = 'article.created';

    public function __construct(protected Article $article) {}

    public function getArticle(): ?Article
    {
        return $this->article;
    }
}
final class ArticleDataPersister implements ContextAwareDataPersisterInterface
{
    // ...

    public function persist($data, array $context = [])
    {
        $object = $this->decorated->persist($data, $context);
        if (
            $object instanceof Article && 
            ('POST' === $context['collection_operation_name'] ?? null)
        ) {
            $this->eventDispatcher->dispatch(new ArticleCreatedEvent($object));
        }
    }

    // ...
}
class AlertSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private NotifierInterface $notifier,
    ) {}

    public function sendAlert(ArticleCreatedEvent $event): void
    {
        $this->notifier->notify($event);
    }

    public static function getSubscribedEvents(): array
    {
        return [
            ArticleCreatedEvent::class => [
                ['sendAlert', 0],
            ],
        ];
    }
}
final class PermissionEngine
{
    public function __construct(
        private UserRepository $repos,
    ) {}

    public function can(User $user, Rule $rule, object $subject): bool
    {
        // ...
    }
    
    public function getAuthorizedUsers(Rule $rule, object $subject): iterable
    {
        $users = $this->repos->findAll();

        foreach ($users as $user) {
            if ($this->can($user, $rule, $subject)) {
                yield $user;
            }
        }
    }
}
class ArticleNotifier implements NotifierInterface
{
  // ...

  /**
   * @param ArticleCreatedEvent $event
   */
  public function notify(AlertableEventInterface $event): void
  {
    $alert = $this->repos->findOneBy(['eventName' => ArticleCreatedEvent::NAME]);
    
    if (!alert || (null === $rule = $alert->getRule())) {
      return;
    }

    $users = $this->permissionEngine->getAuthorizedUsers($rule, $event->getArticle());
    
    foreach ($users as $user) {
      $this->logger->info(
        sprintf('Send alert "%s" to %s"', $alert->getLabel(), $user->getEmail())
      );
    }
  }
}

Using the SecurityBundle in Symfony 6

Wouter de Jong @ SymfonyWorld Winter 2021

http://wouterj.nl/2021/12/security-winterworld21

 

 

Secure and Practical Authentication in API Platform

Robin Chalas @ API Platform Conference 2021

https://www.youtube.com/watch?v=iISeDdwN5lY

MERCI

à toutes & tous !

@marion_age

@coopTilleuls

Gestion des permissions - SymfonyLive Paris 2022

By k-mos

Gestion des permissions - SymfonyLive Paris 2022

  • 1,456