Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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!

Romaric Drigon

August 29, 2019
Tweet

More Decks by Romaric Drigon

Other Decks in Technology

Transcript

  1. Symfony Security
    component in examples
    Romaric Drigon @ Web Summer Camp, Croatia, 29/08/2019

    View full-size slide

  2. Romaric Drigon
    Software engineer
    !
    https://romaricdrigon.github.io/

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  5. Who already...?
    Used @Security annotations?
    Coded a Security Guard?
    Coded a firewall (factory)?
    Hated Symfony Security?

    View full-size slide

  6. This workshop
    1. Authentication
    2. Authorization
    3. Going beyond authentication & authorization
    4. Code kata
    5. Quiz!
    The goal is to go from

    to
    ⭐⭐⭐
    !

    View full-size slide

  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

    View full-size slide

  8. Authentication

    View full-size slide

  9. Authentication: it is about who I am

    View full-size slide

  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)

    View full-size 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('[email protected]');
    $password = $this->encoder->encodePassword($romaric, 'shhhtTellNoOne!');
    $romaric->setPassword($password);
    $manager->persist($romaric);
    // ...
    If lost, you can git checkout 1-user

    View full-size slide

  12. Firewall...
    Let's try to go to the administration, /admin

    View full-size slide

  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

    View full-size slide

  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
    # ...

    View full-size slide

  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

    View full-size slide

  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
    # ...

    View full-size slide

  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);
    }

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  21. Authorization

    View full-size slide

  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...

    View full-size slide

  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]

    View full-size slide

  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

    View full-size slide

  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.

    View full-size slide

  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');
    // ...
    }
    }

    View full-size slide

  27. In Twig
    You can check in Twig templates:
    {% if is_granted('ROLE_ADMIN') %}
    Welcome Mr. Admin!
    {% endif %}

    View full-size slide

  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()
    {
    // ...
    }
    }

    View full-size slide

  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)
    {
    // ...
    }
    }

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  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?).

    View full-size slide

  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();
    // ...
    }
    }

    View full-size slide

  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

    View full-size slide

  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

    View full-size slide

  36. Going beyond
    Authentication & Authorization

    View full-size slide

  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

    View full-size slide

  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).

    View full-size slide

  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...)

    View full-size slide

  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+)

    View full-size slide

  41. Quiz time!
    Please go to kahoot.it to play

    View full-size slide

  42. Thank you for your a!ention
    @romaricdrigon

    View full-size slide