Modern Security with Symfony's Shiny new Security Component

F5dfeeef276fcfd4751f4063487a5a3f?s=47 weaverryan
December 04, 2020

Modern Security with Symfony's Shiny new Security Component

Yes, Symfony's Security component is powerful. But... it's *also* complex. Can we have both? Power and flexibility with readable and expressive code?

We think so. That's why, in Symfony 5.1 & 5.2, the security component was rebuilt and *reimagined*. Complexity was stripped away, logic was centralized and intelligent hook points were added.

The result is a security system that can *do* more with code that you can understand.

In this talk, I'll introduce you to the new security component and show you how to activate and migrate to it (hint: it's simple!). We'll also dive into some of the new possibilities, like streamlined custom authenticators (bye Guard!), centralized "login throttling", magic login links, 2fa and more!

And since no security talk would be complete without API authentication, I'll give you a quick guide to how *you* should implement API auth and how that fits into the new system.

Let's go!

F5dfeeef276fcfd4751f4063487a5a3f?s=128

weaverryan

December 04, 2020
Tweet

Transcript

  1. Modern Security with Symfony's Shiny new Security Component by your

    friend Ryan Weaver @weaverryan
  2. > Member of the Symfony docs team > Some Dude

    at SymfonyCasts.com > Husband of the talented and beloved @leannapelham symfonycasts.com twitter.com/weaverryan Yo! I’m Ryan! > Father to my much more charming son, Beckett
  3. Security System: A History Part 1 @weaverryan

  4. 2010 Today … … …

  5. @weaverryan What Happened?

  6. Massive, massive complexity Original implementation based off of Java Spring

    Powerful Complex … almost no-one understood how it worked @weaverryan
  7. GET /foo X-Token: abc123 Firewall Auth Listeners Unauth'ed Token abc123

    Authentication Manager Auth Providers Auth'ed token Set onto security context Do many other things @weaverryan
  8. The Simple form_login UsernamePasswordFormAuthenticationListener ↪ AbstractAuthenticationListener $token = $this->authenticationManager->authenticate( new

    UsernamePasswordToken($username, $password) ); DaoAuthenticationProvider ↪ UserAuthenticationProvider @weaverryan
  9. Max username length User Checker Almost no Centralization Password checking

    CSRF validation Session fixation Password upgrading AuthSuccessEvent ** This is centralized! Set token on storage In each auth listener In each auth provider Auth Manager ** InteractiveLoginEvent Success handler Notify RememberMe
  10. What about Guard? @weaverryan

  11. Guard: Big Beautiful Bandaid GuardAuthenticationListener ↪ ->getCredentials() ↪ create an

    un-auth'ed token ↪ call AuthenticationManager GuardAuthenticationProvider ↪ ->getUser() ↪ ->checkCredentials() ↪ onAuthenticationSuccess()/Failure() @weaverryan
  12. Excess Complexity Stifles Innovation @weaverryan

  13. The New Security Component Part 2 @weaverryan experimental in 5.2

  14. 2010 Today … …

  15. class User implements UserInterface { // ... public function getUsername():

    string { return (string) $this->email; } public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_unique($roles); } public function getPassword(): string { return (string) $this->password; } public function eraseCredentials() { } } User class (Before) @weaverryan
  16. class User implements UserInterface { // ... public function getUsername():

    string { return (string) $this->email; } public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; return array_unique($roles); } public function getPassword(): string { return (string) $this->password; } public function eraseCredentials() { } } User class (After) @weaverryan
  17. class AdminController extends AbstractController { public function admin() { $this->denyAccessUnlessGranted('ROLE_ADMIN');

    } } Denying Access (Before) # security.yaml security: # ... access_control: - { path: ^/admin, roles: ROLE_ADMIN } @weaverryan
  18. class AdminController extends AbstractController { public function admin() { $this->denyAccessUnlessGranted('ROLE_ADMIN');

    } } # security.yaml security: # ... access_control: - { path: ^/admin, roles: ROLE_ADMIN } Denying Access (After) @weaverryan
  19. security.yaml (Before) security: # ... firewalls: main: lazy: true anonymous:

    true form_login: true guard: authenticators: - App\Security\CustomAuthenticator @weaverryan
  20. security.yaml (After) security: # ... + enable_authenticator_manager: true firewalls: main:

    lazy: true - anonymous: true form_login: true - guard: - authenticators: - - App\Security\CustomAuthenticator + custom_authenticators: + - App\Security\CustomAuthenticator Anonymous users are gone!!! Authenticator classes have a new look @weaverryan Activates the new "mode"
  21. Dont Panic! The changes are beautiful. The migration path is

    short. @weaverryan
  22. POST /login email=r@example.com &password=duck Passport (badges) UserBadge PasswordCredentials CsrfBadge events

    CheckCredentialsListener CsrfProtectionListener SessionStrategyListener UserCheckerListener LoginThrottlingListener PasswordMigrationListener Authenticators Firewall
  23. Custom Authenticators Part 3 @weaverryan

  24. New AuthenticatorInterface @weaverryan interface AuthenticatorInterface { function supports(Request $request): ?bool;

    function createAuthenticatedToken(PassportInterface $passport): TokenInterface; function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response; function onAuthenticationFailure(Request $request, AuthException $exception): ?Response; } function authenticate(Request $request): PassportInterface;
  25. Example 1: Login Form @weaverryan

  26. class SecurityController extends AbstractController { /** * @Route("/login", name="app_login") */

    public function login(): Response { return $this->render('security/login.html.twig'); } } <form method="post"> <input type="email" name="email"> <input type="password" name="password"> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" > <button type="submit"> Sign in </button> </form>
  27. // src/Security/LoginFormAuthenticator class LoginFormAuthenticator extends AbstractLoginFormAuthenticator { public function supports(Request

    $request): ?bool { } public function authenticate(Request $request): PassportInterface { } public function onAuthenticationSuccess(Request $request, TokenInterface $token): ?Response { } protected function getLoginUrl(Request $request): string { } }
  28. public function supports(Request $request): ?bool { return $request->attributes->get('_route') === 'app_login'

    && $request->isMethod('POST'); }
  29. public function authenticate(Request $request): PassportInterface { $email = $request->request->get('email'); $password

    = $request->request->get('password'); return new Passport( new UserBadge($email, function ($email) { return $this->userRepository->findOneBy(['email' => $email]); }), new PasswordCredentials($password) ); }
  30. public function authenticate(Request $request): PassportInterface { $email = $request->request->get('email'); $password

    = $request->request->get('password'); return new Passport( new UserBadge($email, function ($email) { return $this->userRepository->findOneBy(['email' => $email]); }), new PasswordCredentials($password) ); }
  31. public function authenticate(Request $request): PassportInterface { $email = $request->request->get('email'); $password

    = $request->request->get('password'); return new Passport( new UserBadge($email, function ($email) { return $this->userRepository->findOneBy(['email' => $email]); }), new PasswordCredentials($password) ); }
  32. public function authenticate(Request $request): PassportInterface { $email = $request->request->get('email'); $password

    = $request->request->get('password'); return new Passport( new UserBadge($email, function ($email) { return $this->userRepository->findOneBy(['email' => $email]); }), new PasswordCredentials($password) ); }
  33. public function authenticate(Request $request): PassportInterface { $email = $request->request->get('email'); $password

    = $request->request->get('password'); return new Passport( new UserBadge($email, function ($email) { return $this->userRepository->findOneBy(['email' => $email]); }), new PasswordCredentials($password) ); }
  34. Done! @weaverryan

  35. User Checker Event Listeners: Authentication Work Horses Password checking CSRF

    validation Session fixation Password upgrading Set token on storage Notify RememberMe Events: 6 centralized events
  36. Badges: Extra Passport Info @weaverryan return new Passport( new UserBadge($email,

    function ($email) { // ... }), new PasswordCredentials($password), [ // badges ] );
  37. Badges: CSRF Protection <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" >

    $csrfToken = $request->request->get('_csrf_token'); return new Passport( new UserBadge($email, function ($email) { // ... }), new PasswordCredentials($password), [ new CsrfTokenBadge('authenticate', $csrfToken) ] ); Badges can require that a listener "resolves" them.
  38. Badges: Password Upgrading return new Passport( new UserBadge($email, function ($email)

    { // ... }), new PasswordCredentials($password), [ new CsrfTokenBadge('authenticate', $csrfToken), new PasswordUpgradeBadge($password, $this->userRepository) ] ); This PasswordUpgradeBadge is no longer required. Your password will be upgraded automatically as long as your user provider implements the upgrader interface (which happens always if you use make:user)
  39. Possibilities? Login Throttling & Events @weaverryan

  40. security: # ... firewalls: main: # ... # max at

    3 login attempts per minute login_throttling: max_attempts: 3 $ composer require symfony/rate-limiter
  41. Events, Events, Events! CheckPassportEvent Has access to the Passport &

    all badges AuthenticatedTokenCreatedEvent Has access - and can change - the authenticated token LoginSuccessEvent Success! Has Passport, Token, User & Response LoginFailureEvent Failure :( . But, has to the auth exception, Passport & Response
  42. Legacy Events AuthenticationSuccessEvent It's fine, but LoginSuccessEvent is more powerful

    InteractiveLoginEvent Don't use: what *is* an "interactive" login? Don't use these: there are better events now!
  43. And… Core Authenticators are Readable! @weaverryan

  44. class JsonLoginAuthenticator implements AuthenticatorInterface { public function authenticate(Request $request): PassportInterface

    { $credentials = $this->getCredentials($request); $passport = new Passport(new UserBadge($credentials['username'], function ($username) { return $this->userProvider->loadUserByUsername($username); }), new PasswordCredentials($credentials['password'])); if ($this->userProvider instanceof PasswordUpgraderInterface) { $passport->addBadge( new PasswordUpgradeBadge($credentials['password'], $this->userProvider ) ); } return $passport; } } json_login
  45. API Token Authentication @weaverryan

  46. public function supports(Request $request): ?bool { return $request->headers->has('X-AUTH-TOKEN'); }

  47. public function authenticate(Request $request): PassportInterface { $token = $request->headers->get('X-AUTH-TOKEN'); return

    new SelfValidatingPassport( new UserBadge($token, function($token) { $apiToken = $this->apiTokenRepository->findOneBy([ 'token' => $token ]); if (null === $apiToken) { throw new CustomUserMessageAuthenticationException( 'Invalid API token' ); } return $apiToken->getUser(); }) ); }
  48. public function authenticate(Request $request): PassportInterface { $token = $request->headers->get('X-AUTH-TOKEN'); return

    new SelfValidatingPassport( new UserBadge($token, function($token) { $apiToken = $this->apiTokenRepository->findOneBy([ 'token' => $token ]); if (null === $apiToken) { throw new CustomUserMessageAuthenticationException( 'Invalid API token' ); } return $apiToken->getUser(); }) ); }
  49. public function authenticate(Request $request): PassportInterface { $token = $request->headers->get('X-AUTH-TOKEN'); return

    new SelfValidatingPassport( new UserBadge($token, function($token) { $apiToken = $this->apiTokenRepository->findOneBy([ 'token' => $token ]); if (null === $apiToken) { throw new CustomUserMessageAuthenticationException( 'Invalid API token' ); } return $apiToken->getUser(); }) ); }
  50. Various other Improvements Part 4 @weaverryan

  51. Anon. Anonymous Users are Gone! security: # ... access_control: -

    { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/, roles: ROLE_USER } • If a user isn't authenticated, the User is null • If you need to explicitly allow access:
  52. 2fa No Longer Requires Hacks

  53. LogoutEvent class LogoutSubscriber implements EventSubscriberInterface { public function onLogout(LogoutEvent $event)

    { $user = $event->getToken()->getUser(); // ... $response = new RedirectResponse('...'); $event->setResponse($response); } public static function getSubscribedEvents() { return [LogoutEvent::class => 'onLogout'], } }
  54. Magic Login Link security: firewalls: main: login_link: check_route: login_check signature_properties:

    ['id']
  55. Magic Login Link public function requestLoginLink(LoginLinkHandlerInterface $loginLinkHandler) { if ($request->isMethod('POST'))

    { $email = $request->request->get('email'); $user = $userRepository->findOneBy(['email' => $email]); $loginLinkDetails = $loginLinkHandler->createLoginLink($user); $loginLink = $loginLinkDetails->getUrl(); // send it with Notifier } }
  56. Complexity is Gone @weaverryan

  57. What else can we make? @weaverryan

  58. Me: @weaverryan THANK YOU! Security Hero: @wouterj