@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
# config/packages/framework.yaml
framework:
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
https://github.com/symfony/security-bundle/
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
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 ✔️
$ 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
$ 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
$ 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
$ 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;
}
# 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
# 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
# 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 # ...
# 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 # ...
# 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 # ...
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.
Le Passport est un objet qui contient :
Source : symfony.com
interface UserCheckerInterface
{
public function checkPreAuth(UserInterface $user);
public function checkPostAuth(UserInterface $user);
}
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;
}
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
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
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 {}
}
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;
}
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(); } }
# 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 }
# 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 }
#IsGranted('ROLE_EDITOR')
class ArticleController extends AbstractController
{
#IsGranted('ARTICLE_MANAGE', subject: 'article')
public function manageArticle(Article $article)
{
// $this->denyAccessUnlessGranted('ARTICLE_MANAGE', $article);
}
}
is_granted('ROLE_ADMIN')
# 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);
}),
];
}
}
#[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; } }
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; } }
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); } }
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; }
#[ApiResource( itemOperations: [ 'GET' => [ 'security' => "is_granted('ARTICLE_MANAGE', object)", ], ], )] class Article {} class User { #[ApiProperty(security: "is_granted('ADDRESS_SHOW', object)")] private ?string $address = null; }
#[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],
],
];
}
}
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
@marion_age
@coopTilleuls