Symfony Security component in examples

Symfony Security component in examples

Symfony Security component is amongst the hardest to understand for newcomers, due to the huge numbers of new concepts. As a consequence, it is often not used or misused. This workshop will go through typical scenarios you may encounter, as adding an authentication to an API, how to set up authorization easily, or how to handle fine-grained access control. For each, we will see which tools you can use, what are the best practises and what different projects taught me. Let's see how we can do more with less custom code!

88a681988c6744a099be88084dedb545?s=128

Romaric Drigon

August 29, 2019
Tweet

Transcript

  1. Symfony Security component in examples Romaric Drigon @ Web Summer

    Camp, Croatia, 29/08/2019
  2. Romaric Drigon Software engineer ! https://romaricdrigon.github.io/

  3. ! #websc " @romaricdrigon # http://events.netgen.io/

  4. This is a workshop where you are the hero!

  5. Who already...? Used @Security annotations? Coded a Security Guard? Coded

    a firewall (factory)? Hated Symfony Security?
  6. This workshop 1. Authentication 2. Authorization 3. Going beyond authentication

    & authorization 4. Code kata 5. Quiz! The goal is to go from ⭐ to ⭐⭐⭐ !
  7. Ge!ing started cd /home/websc/www/symfony/security git pull composer install bin/console doctrine:migrations:migrate

    bin/console doctrine:fixtures:load cp .env.local .env.test.local bin/phpunit symfony serve
  8. Authentication

  9. Authentication: it is about who I am

  10. First, we need users We will use Symfony Maker bundle:

    # Create a "User" entity, with an "email" property, and a hashed password bin/console make:user # Add a Blog::owner property, ManyToOne pointing to User, unidirectional bin/console make:entity Blog # Generate a migration, and run it bin/console make:migration bin/console doctrine:migrations:migrate # We will edit data fixtures together to add some users, # and owners to Blogs (next slide)
  11. Hashing the password use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; class AppFixtures extends Fixture {

    private $encoder; public function __construct(UserPasswordEncoderInterface $encoder) { $this->encoder = $encoder; } public function load(ObjectManager $manager) { $romaric = (new User())->setEmail('romaric.drigon@gmail.com'); $password = $this->encoder->encodePassword($romaric, 'shhhtTellNoOne!'); $romaric->setPassword($password); $manager->persist($romaric); // ... If lost, you can git checkout 1-user
  12. Firewall... Let's try to go to the administration, /admin

  13. Firewalls 2 ! # config/packages/security.yaml security: firewalls: # The URL

    is matched against "pattern" - first wins dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ # (1) We can totally disable security security: false admin: pattern: ^/admin # (3) Or not, and we get an exception # Because Symfony has no way there to authenticate users anonymous: false main: pattern: ^/ # default value if we omit pattern # (2) Or give anyone "anonymous" access anonymous: true
  14. Let's simplify configuration For this example, please edit your config

    as below: # config/packages/security.yaml security: # ... firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: anonymous: true # ...
  15. Adding a login form with Guard Make a "login form"

    authenticator: bin/console make:auth Use any name for the authenticator, and we want a /logout URL
  16. # config/packages/security.yaml security: providers: app_user_provider: entity: class: App\Entity\User property: email

    encoders: App\Entity\User: algorithm: auto firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: anonymous: true provider: app_user_provider # Added for clarity (default value) guard: authenticators: - App\Security\FormAuthenticator logout: path: app_logout # ...
  17. namespace Symfony\Component\Security\Guard\Firewall; interface AuthenticatorInterface extends AuthenticatorInterface { // Can I

    try to read authentication data from this request? // Returns true / false (end of the process) public function supports(Request $request); // Read authentication data from the request. // Returns it [mixed] or null (failure). Typically we return an array with username and password. public function getCredentials(Request $request); // Fetch an User from given username, from User provider. // Returns it (UserInterface) or null (failure) public function getUser($credentials, UserProviderInterface $userProvider); // Check credentials versus User, typically we compare password. // Returns true if good, anything else will cause authentication failure public function checkCredentials($credentials, UserInterface $user); // Called when one of the previous methods failed. // Returns Response to the user (ie., RedirectResponse to login or a 403) // Returns null for silent failure (user is not authenticated) public function onAuthenticationFailure(Request $request, AuthenticationException $exception); // Called when authentication suceeded. // Returns Response to the user (ie., RedirectResponse to admin or previous page) // Returns null for silent success (user stays on the same page) public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey); // Returns true to indicate it supports remmember me cookies public function supportsRememberMe(); // Inherited from AuthenticationEntryPointInterface: handles an unconnected user (ie., redirect or 401) public function start(Request $request, AuthenticationException $authException = null); }
  18. Finishing the authenticator We have to redirect User after login:

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) { // ... return new RedirectResponse($this->urlGenerator->generate('admin')); } If blocked: git checkout 2-login
  19. To recap To customize Symfony authentication: 1. the Guard authenticator

    — It handles the authentication process. — You generate it with make:auth 2. the User Provider - documentation — It fetches an User from credentials (username). — Symfony provides some (in_memory, Doctrine entity), or you can write your own (UserProviderInterface). 3. the Password Encoder — It hashes password. — Provided by Symfony (bcrypt, argon2i...). — You register it by User
  20. Exercise We want to secure the API: only authenticated clients

    (ie., mobile apps) can access it. Authentication will use Authorization HTTP header. — add a new firewall over /api — it has to have stateless: true — you can set anonymous: true temporarily (to use Maker) — a new User Provider (in_memory is fine) — set any Password Encoder in config (not used) — add a new Guard authenticator — modify API functional test (and add a failing one) If blocked or curious: git checkout 3-api-auth
  21. Authorization

  22. Roles Authorization is about what an User can do. User

    has roles: - ROLE_USER (by default) - ROLE_ADMIN, ROLE_SUPER_ADMIN are common - and your owns...
  23. Role hierarchy Usually, roles are hierarchical. You can configure it

    so you don't have to give all roles & sub-roles to every user: # config/packages/security.yaml security: role_hierarchy: ROLE_ADMIN: ROLE_USER ROLE_SUPER_ADMIN: [ROLE_ADMIN]
  24. Authentication roles There are 3 specific roles. They are issued

    by the firewall (authentication), so you can check how the user was authenticated. — IS_AUTHENTICATED_ANONYMOUSLY is given to all users who went through a firewall (including anonymous one) — IS_AUTHENTICATED_REMEMBERED is given to users authenticated through a "remember me" cookie — IS_AUTHENTICATED_FULLY is given to users who authenticated themselves in this session ❗ those roles are hierarchical
  25. Access control rules You can quickly secure part of your

    application using those: # config/packages/security.yaml security: access_control: # You can also add checks by IP, host, etc. - { path: ^/admin, ip: 127.0.0.1, roles: ROLE_USER } - { path: ^/admin, host: localhost, roles: ROLE_USER } # Some usual rules: - { path: ^/admin/hidden-page, roles: ROLE_SUPER_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN } # Bonus: you can force HTTPS (if the rule is matched) - { path: ^/, requires_channel: https } They are executed before routing. First path matching wins.
  26. In a controller (1/2) For more granular checks, you can

    check inside a controller action: class AdminController extends AbstractController { public function index() { // Long and verbose version, using the service if (!$this->get('security.authorization_checker')->isGranted('ROLE_ADMIN')) { throw $this->createAccessDeniedException(); } // Short version (if you extend AbstractController) $this->denyAccessUnlessGranted('ROLE_ADMIN'); // ... } }
  27. In Twig You can check in Twig templates: {% if

    is_granted('ROLE_ADMIN') %} <p>Welcome Mr. Admin!</p> {% endif %}
  28. Annotations in a controller (1/2) Over controller actions, you can

    alternatively use annotations: use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; class AdminController extends AbstractController { /** * Both below are equivalent (and redundant): * @Security("is_granted('ROLE_ADMIN')") * @IsGranted("ROLE_ADMIN") */ public function index() { // ... } }
  29. Annotations in a controller (1/2) @Security supports Expression Language: class

    BlogController extends AbstractController { /** * @Security("is_granted('ROLE_ADMIN') and blog.getOwner().getId() == user.getId()") */ public function edit(Blog $blog, Request $request) { // ... } }
  30. Help me secure the application — implement described role hierarchy

    — modify data fixtures so one User has ROLE_ADMIN — restricts "Admin" to admins only — only admins owning a Blog can edit it or its articles — bonus: toggle login/logout links in navbar If blocked: git checkout 4-roles
  31. A be!er solution We will code together a Security Voter.

    Let's start by having Maker bundle generating a skeleton: bin/console make:voter Then please make an ArticleVoter, for articles. If blocked: git checkout 5-voter
  32. Security Voter Voters allow to handle granular permissions. They are

    powerful, and flexible. They should be unit tested. But they are too flexible, Symfony Security by itself is unopiniated: you have to defines yourself which attributes you need, and how you want to check those. You have to share those guidelines in your team, and enforce those (code reviews?).
  33. One missing bit The administration page is showing all Blogs,

    instead of only blogs I can edit: class AdminController extends AbstractController { /** * @Route("/admin", name="admin") * @Security("is_granted('ROLE_ADMIN')") */ public function index() { $blogs = $this->getDoctrine()->getRepository(Blog::class)->findAll(); // ... } }
  34. Filtering data Here, Symfony Security component won't help us. We

    have to filter data manually: class AdminController extends AbstractController { /** * @Route("/admin", name="admin") * @Security("is_granted('ROLE_ADMIN')") */ public function index() { $blogs = $this->getDoctrine()->getRepository(Blog::class)->findBy([ 'owner' => $this->getUser(), ]); } } Or we can use a Doctrine filter to filter Blog queries
  35. Doctrine filter example class BlogFilter extends SQLFilter { public function

    addFilterConstraint(ClassMetadata $entityMetadata, $alias) { if (Blog::class !== $entityMetadata->reflClass->getName()) { return ''; } $user = $this->getParameter('user'); if (null === user) { throw new \Exception('User was not set!'); } return $alias.'.owner === $user; // This DQL will be injected in 'WHERE' } } Full code in this gist
  36. Going beyond Authentication & Authorization

  37. User Checker You can add an UserChecker, which will be

    called during the authentication process to check if User is valid. class UserChecker implements UserCheckerInterface { public function checkPreAuth(UserInterface $user) {} public function checkPostAuth(UserInterface $user) { if (!$user instanceof User) { // Always check return; } if ($user->isExpired()) { throw new AccountExpiredException('...'); } } } Typical use case: User expiration date
  38. Cross-Site Request Forgery In a CSRF attack, an attacker makes

    an User do something they don't want. And sometimes, without them knowing. Example scenario: 1. an Admin logins to the myapp.com application 2. they check later their e-mail, or browse a malicious website 3. this website redirects them to myapp.com/admin/blog/delete/1 4. because they are authenticated, a Blog is deleted This is especially problematic if you use Sonata or EasyAdmin (routes are easy to guess).
  39. CSRF protection You can set framework.session.cookie_samesite to lax (default with

    Flex, partial protection) or strict (degrades UX). Symfony Security helps handling CSRF tokens: — this is done automatically in Symfony Forms — consider using it manually over sensitive routes you create (delete over GET...)
  40. Code kata — finish securing your application, and add a

    Doctrine filter — add an expiration date to Users, check it with a User Checker — implement a rate limiting over login — you will need to hook into form login Guard — I advise you to dispatch an event, and to have your logic in an EventSubscriber — after login, warn users whose password was compromised — again, you have to hook into form login Guard — you can call Symfony Validator, with the new NotCompromisedValidator (4.3+)
  41. Quiz time! Please go to kahoot.it to play

  42. Thank you for your a!ention @romaricdrigon