Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

This is a workshop where you are the hero!

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

Authentication

Slide 9

Slide 9 text

Authentication: it is about who I am

Slide 10

Slide 10 text

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)

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

Authorization

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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]

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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.

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

In Twig You can check in Twig templates: {% if is_granted('ROLE_ADMIN') %}

Welcome Mr. Admin!

{% endif %}

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

Going beyond Authentication & Authorization

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

Quiz time! Please go to kahoot.it to play

Slide 42

Slide 42 text

Thank you for your a!ention @romaricdrigon