Preface
We recently had the opportunity to work on a Symfony app for one of our Higher Ed clients that we recently built a Drupal distribution for. Drupal 8 moving to Symfony has enabled us to expand our service offering. We have found more opportunities building apps directly using Symfony when a CMS is not needed. This post is not about Drupal, but cross posting to Drupal Planet to demonstrate the value of getting off the island. Enjoy!
Writing custom authentication schemes in Symfony used to be on the complicated side. But with the introduction of the Guard authentication component, it has gotten a lot easier.
One of our recent projects required use to interface with Shibboleth to authenticate users into the application. The application was written in Symfony 2 and was using this bundle to authenticate with Shibboleth sessions. However, since we were rewriting everything in Symfony 3 which the bundle is not compatible with, we had to look for a different solution. Fortunately for us, the built-in Guard authentication component turns out to be a sufficient solution, which allows us to drop a bundle dependency and only requiring us to write only one class. Really neat!
How Shibboleth authentication works
One way Shibboleth provisions a request with an authenticated entity is by setting a "remote user" environment variable that the web-server and/or residing applications can peruse.
There is obviously more to Shibboleth than that; it has to do a bunch of stuff to do the actual authenticaiton process. We defer all the heavy-lifting to the
mod_shib
Apache2 module, and rely on the availability of theREMOTE_USER
environment variable to identify the user.
That is pretty much all we really need to know; now we can start writing our custom Shibboleth authentication guard:
<?php
namespace AppBundle\Security\Http;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;
class ShibbolethAuthenticator extends AbstractGuardAuthenticator implements LogoutSuccessHandlerInterface
{
/**
* @var
*/
private $idpUrl;
/**
* @var null
*/
private $remoteUserVar;
/**
* @var UrlGeneratorInterface
*/
private $urlGenerator;
public function __construct(UrlGeneratorInterface $urlGenerator, $idpUrl, $remoteUserVar = null)
{
$this->idpUrl = $idpUrl;
$this->remoteUserVar = $remoteUserVar ?: 'HTTP_EPPN';
$this->urlGenerator = $urlGenerator;
}
protected function getRedirectUrl()
{
return $this->urlGenerator->generateUrl('shib_login');
}
/**
* @param Request $request The request that resulted in an AuthenticationException
* @param AuthenticationException $authException The exception that started the authentication process
*
* @return Response
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$redirectTo = $this->getRedirectUrl();
if (in_array('application/json', $request->getAcceptableContentTypes())) {
return new JsonResponse(array(
'status' => 'error',
'message' => 'You are not authenticated.',
'redirect' => $redirectTo,
), Response::HTTP_FORBIDDEN);
} else {
return new RedirectResponse($redirectTo);
}
}
/**
* @param Request $request
*
* @return mixed|null
*/
public function getCredentials(Request $request)
{
if (!$request->server->has($this->remoteUserVar)) {
return;
}
$id = $request->server->get($this->remoteUserVar);
if ($id) {
return array('eppn' => $id);
} else {
return null;
}
}
/**
*
* @param mixed $credentials
* @param UserProviderInterface $userProvider
*
* @throws AuthenticationException
*
* @return UserInterface|null
*/
public function getUser($credentials, UserProviderInterface $userProvider)
{
return $userProvider->loadUserByUsername($credentials['eppn']);
}
/**
* @param mixed $credentials
* @param UserInterface $user
*
* @return bool
*
* @throws AuthenticationException
*/
public function checkCredentials($credentials, UserInterface $user)
{
return true;
}
/**
* @param Request $request
* @param AuthenticationException $exception
*
* @return Response|null
*/
public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
{
$redirectTo = $this->getRedirectUrl();
if (in_array('application/json', $request->getAcceptableContentTypes())) {
return new JsonResponse(array(
'status' => 'error',
'message' => 'Authentication failed.',
'redirect' => $redirectTo,
), Response::HTTP_FORBIDDEN);
} else {
return new RedirectResponse($redirectTo);
}
}
/**
* @param Request $request
* @param TokenInterface $token
* @param string $providerKey The provider (i.e. firewall) key
*
* @return Response|null
*/
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
return null;
}
/**
* @return bool
*/
public function supportsRememberMe()
{
return false;
}
/**
* @param Request $request
*
* @return Response never null
*/
public function onLogoutSuccess(Request $request)
{
$redirectTo = $this->urlGenerator->generate('shib_logout', array(
'return' => $this->idpUrl . '/profile/Logout'
));
return new RedirectResponse($redirectTo);
}
}
Let's break it down:
-
class ShibbolethAuthenticator extends AbstractGuardAuthenticator ...
- We'll extend the built-in abstract to take care of the non-Shibboleth specific plumbing required. -
__construct(...)
- As you would guess, we are passing in all the things we need for the authentication guard to work; we are getting the Shibboleth iDP URL, the remote user variable to check, and the URL generator service which we need later. -
getRedirectUrl()
- This is just a convenience method which returns the Shibboleth login URL. -
start(...)
- This is where everything begins; this method is responsible for producing a request that will help the Security component drive the user to authenticate. Here, we are simply either 1.) redirecting the user to the Shibboleth login page; or 2.) producing a JSON response that tells consumers that the request is forbidden, if the client is expectingapplication/json
content back. In which case, the payload will conveniently inform consumers where to go to start authenticating via theredirect
property. Our front-end application knows how to handle this. -
getCredentials(...)
- This method is responsible for extracting authentication credentials from the HTTP request i.e. username and password, JWT token in theAuthorization
header, etc. Here, we are interested in the remote user environment variable thatmod_shib
might have set for us. It is important that we check that the environment variable is actually not empty becausemob_shib
will still have it set but leaves it empty for un-authenticated sessions. -
getUser(...)
- Here we get the credentials thatgetCredentials(...)
returned and construct a user object from it. The user provider will also be passed into this method; whatever it is that is configured for the firewall. -
checkCredentials(...)
- Following thegetUser(...)
call, the security component will call this method to actually verify whether or not the authentication attempt is valid. For example, in form logins, this is where you would typically check the supplied password against the encrypted credentials in the the data-store. However we only need to returntrue
unconditionally, since we are trusting Shibboleth to filter out invalid credentials and only let valid sessions to get through to the application. In short, we are already expecting a pre-authenticated request. -
onAuthenticationFailure(...)
- This method is called whenever our authenticator reports invalid credentials. This shouldn't really happen in the context of a pre-authenticated request as we 100% entrust the process to Shibboleth, but we'll fill this in with something reasonable anyway. Here we are simply replicating whatstart(...)
does. -
onAuthenticationSuccess(...)
- This method gets called when the credential checks out, which is all the time. We really don't have to do anything but to just let the request go through. Theoretically, this would be there we can bootstrap the token with certain roles depending on other Shibboleth headers present in theRequest
object, but we really don't need to do that in our application. -
supportsRememberMe(...)
- We don't care about supporting "remember me" functionality, so no, thank you! -
onLogoutSuccess(...)
- This is technically not part of the Guard authentication component, but to thelogout
authentication handler. You can see that ourShibbolethAuthenticator
class also implementsLogoutSuccessHandlerInterface
which will allow us to register it as a listener to the logout process. This method will be responsible for clearing out Shibboleth authentication data after Symfony has cleared the user token from the system. To do this we just need to redirect the user to the proper Shibboleth logout URL, and seeding thereturn
parameter to the nice logout page in the Shibboleth iDP instance.
Configuring the router: shib_login
and shib_logout
routes
We'll update app/config/routing.yml
:
# app/config/routing.yml
shib_login:
path: /Shibboleth.sso/Login
shib_logout:
path: /Shibboleth.sso/Logout
You maybe asking yourself why we even bother creating known routes for these while we can just as easily hard-code these values to our guard authenticator.
Great question! The answer is that we want to be able to configure these to point to an internal login form for local development purposes, where there is no value in actually authenticating with Shibboleth, if not impossible. This allows us to override the shib_login
path to /login
within routing_dev.yml
so that the application will redirect us to the proper login URL in our dev environment.
We really can't point shib_logout
to /logout
, though, as it will result in an infinite redirection loop. What we do is override it in routing_dev.yml
to go to a very simple controller-action that replicates Shibboleth's logout URL external behavior:
<?php
...
public function mockShibbolethLogoutAction(Request $request)
{
$return = $request->get('return');
if (!$return) {
return new Response("`return` query parameter is required.", Response::HTTP_BAD_REQUEST);
}
return $this->redirect($return);
}
}
Configuring the firewall
This is the last piece of the puzzle; putting all these things together.
########################################################
# 1. We register our guard authenticator as a service #
########################################################
# app/config/services.yml
services:
app.shibboleth_authenticator:
class: AppBundle\Security\Http\ShibbolethAuthenticator
arguments:
- '@router'
- '%shibboleth_idp_url%'
- '%shibboleth_remote_user_var%'
---
##########################################################################
# 2. We configure Symfony to read security_dev.yml for dev environments. #
##########################################################################
# app/config/config_prod.yml
imports:
- { resources: config.yml }
- { resources: security.yml }
---
# app/config/config_dev.yml
imports:
- { resources: config.yml }
- { resources: security_dev.yml } # Dev-specific firewall configuration
---
#####################################################################################
# 3. We configure the app to use the `guard` component and our custom authenticator #
#####################################################################################
# app/config/security.yml
security:
firewall:
main:
stateless: true
guard:
authenticators:
- app.shibboleth_authenticator
logout:
path: /logout
success_handler: app.shibboleth_authenticator
---
#####################################################
# 4. Configure dev environments to use `form_login` #
#####################################################
# app/config/security_dev.yml
security:
firewall:
main:
stateless: false
form_login:
login_path: shib_login
check_path: shib_login
target_path_parameter: return
The star here is actually just what's in the security.yml
file, specifically the guard
section; that's how simple it is to support custom authentication via the Guard authentication component! It's just a matter of pointing it to the service and it will hook it up for us.
The logout
configuration tells the application to allocate the /logout
path to initiate the logout process which will eventually call our service to clean up after ourselves.
You also notice that we actually have security_dev.yml
file here that config_dev.yml
imports. This isn't how the Symfony 3 framework ships, but this allows us to override the firewall configuration specifically for dev environments. Here, we add the form_login
authentication scheme to support logging in via an in-memory user-provider (not shown). The authentication guard will redirect us to the in-app login form instead of the Shibboleth iDP during development.
Also note the stateless
configuration difference between prod and dev: We want to keep the firewall in production environments stateless; this just means that our guard authenticator will get consulted in all requests. This ensures that users will actually be logged out from the application whenever they are logged out of the Shibboleth iDP i.e. when they quit the web browser, etc. However we need to configure the firewall to be stateful during development, otherwise the form_login
authentication will not work as expected.
Conclusion
I hope I was able to illustrate how versatile the Guard authentication component in Symfony is. What used to require multiple classes to be written and wired together now only requires a single class to implement, and its very trivial to configure. The Symfony community has really done a great job at improving the Developer Experience (DX).
Footnote
Setting pre-authenticated requests via environment variables isn't just used by
mod_shib
, but also by other authentication modules as well, likemod_auth_kerb
,mod_auth_gssapi
, andmod_auth_cas
. It's a well-adopted scheme that Symfony actually ships with aremote_user
authentication listener starting 2.6 that makes it very easy to integrate with them. Check it out if your needs are simpler i.e. no custom authentication-starter/redirect logic, etc.