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