/SingleSignOnIdentityProviderBundle

Single Sign On bundle for Symfony3 projects. IdP part.

Primary LanguagePHPMIT LicenseMIT

Single Sign On Identity Provider - Extension

Below is forked readme from korotovsky, here will be explained what this extension provides:

  • There is always a situation when SP1 is most important project and other ones SP2, SP3 are not so much important, in original implementation if we log out from SP1 we in SSO loop trigger log out from SP2 and the rest of SP's, but if SP2 has some critical error it will return response code 404, or if host is unknown response code 0, so sso/logout will break and we cant log out from SP1. These situations are now handled in LogoutManager so logout is not triggered on SPs that return response codes 404 or 0 and logout on IDP and rest ISPs will be successful.
  • Branch 0.3.X of IDP is not filling properly _security.main.target_path as 0.2.X does. On 0.3.X this information stays in url as _target_path parameter so its retrieved from there. Look example of login route below.
  • On IDP is registration, that registration link can lead to IDP registration page that is common for all SPs, but in situations where we want registration page of SP from which user came to IDP we must extend Service provider for registration url getter and then extract proper registration page of SP in IDP login route. Look example below.
/**
* @Route("/login", name="login")
*/
public function loginAction(Request $request) {

   $serviceManager = $this->get('krtv_single_sign_on_identity_provider.manager.service_manager');

   // populating propper target path
   $target_path = $request->getSession()->get('_security.main.target_path');
   if ($request->query->get('_target_path')){
       $target_path = $request->query->get('_target_path');
   }
   $request->getSession()->set('_security.main.target_path', $target_path);

   if ($this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) {
       return $this->redirect($target_path);
   }

   //getting register url
   $service = substr($target_path, strpos($target_path, 'service=') + 8);
   $availableServices = $serviceManager->getServices();
   if (!in_array($service, $availableServices)){
       $service = 'YOUR_DEFAULT_SP';
   }
   $serviceProvider = $serviceManager->getServiceManager($service);
   $register_url = $serviceProvider->getServiceRegisterUrl($service);

   // getting errors if any
   $authenticationUtils = $this->get('security.authentication_utils');
   $error = $authenticationUtils->getLastAuthenticationError();
   $lastUsername = $authenticationUtils->getLastUsername();

   return $this->render("security/login.html.twig", array(
       'register_url' => $register_url,
       'error' => $error,
       'lastUsername' => $lastUsername,
       'target_path' => $target_path
   ));
}
  • With original SSO implementation there was a problem when we are authenticated on IDP and SP1, but not on SP2. So when we visit SP2 public route SSO authentication is not triggered and user sees the public page of SP2 as not logged in user. For user to be logged in on all SPs javascript file authenticate.js should be present on IDP which SPs will include. And appropriate CORS settings set on http server.
var hosts = ['http:/SP1.com','http://SP2.com','http://SP3.com','http://SP4.com'];

for (var i=0; i < hosts.length; i++){
    $.ajax({
        'url': hosts[i] + '/sso/authenticate-user',
        'type':'get',
         xhrFields: { withCredentials: true },
         success: function (data, textStatus, jqXHR) {
             console.log('succes ' + hosts[i]);
         },
         error: function (jqXHR, textStatus, errorThrown) {
             console.log('errror');
         }
    });
}

var url = window.location.href;
url = url.replace('authAll=true', '');
if (url.slice(-1) == '?'){
    url = url.replace('?', '');
}

history.pushState({}, null, url);

Note

PHP session names on IDP and all SPs should be different and set in config.yml with ie:

framework
	session:
        name: SP1SESSID

Single Sign On Identity Provider

Build Status Scrutinizer Code Quality Code Coverage SensioLabsInsight

Disclaimer

I am by no means a security expert. I'm not bad at it either, but I cannot vouch for the security of this bundle. You can use this in production if you want, but please do so at your own risk. That said, if you'd like to contribute to make this bundle better/safer, you can always create an issue or send a pull request.

Description

This bundle provides an easy way to integrate a single-sign-on in your website. It uses an existing ('main') firewall for the actual authentication, and redirects all configured SSO-routes to authenticate via a one-time-password.

Installation

Installation is a 10 steps process:

  1. Download SingleSignOnIdentityProviderBundle using composer
  2. Enable the bundle
  3. Create service provider(s)
  4. Configure SingleSignOnIdentityProviderBundle
  5. Enable the route to validate OTP
  6. Modify security settings
  7. Add / Modify login and logout success handlers
  8. Create OTP route
  9. Add redirect path to login form
  10. Update database schema

Step 1: Download SingleSignOnIdentityProviderBundle using composer

Tell composer to require the package:

composer require korotovsky/sso-idp-bundle

Composer will install the bundle to your project's vendor/korotovsky directory.

Step 2: Enable the bundle

<?php
// app/AppKernel.php

public function registerBundles()
{
    $bundles = [
        // ...
        new Krtv\Bundle\SingleSignOnIdentityProviderBundle\KrtvSingleSignOnIdentityProviderBundle(),
    ];
}
?>

Step 3: Create service provider(s)

You have to create a ServiceProvider for each application that uses the SSO SP bundle.

Each ServiceProvider must implement Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceProviderInterface.

<?php
// src/AcmeBundle/ServiceProviders/Consumer1.php

namespace AcmeBundle\ServiceProviders;

use Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceProviderInterface;

/**
 * Consumer 1 service provider
 */
class Consumer1 implements ServiceProviderInterface
{
    /**
     * Get name of the service
     *
     * @return string
     */
    public function getName()
    {
        return 'consumer1';
    }

    /**
     * Get service provider index url
     *
     * @param  array  $parameters
     *
     * @return string
     */
    public function getServiceIndexUrl($parameters = [])
    {
        return 'http://consumer1.com/';
    }

    /**
     * Get service provider logout url
     *
     * @param  array  $parameters
     *
     * @return string
     */
    public function getServiceLogoutUrl($parameters = [])
    {
        return 'http://consumer1.com/logout';
    }
}
?>
<?php
// src/AcmeBundle/ServiceProviders/Consumer2.php

namespace AcmeBundle\ServiceProviders;

use Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceProviderInterface;

/**
 * Consumer 2 service provider
 */
class Consumer2 implements ServiceProviderInterface
{
    /**
     * Get name of the service
     *
     * @return string
     */
    public function getName()
    {
        return 'consumer2';
    }

    /**
     * Get service provider index url
     *
     * @param  array  $parameters
     *
     * @return string
     */
    public function getServiceIndexUrl($parameters = [])
    {
        return 'http://consumer2.com/';
    }

    /**
     * Get service provider logout url
     *
     * @param  array  $parameters
     *
     * @return string
     */
    public function getServiceLogoutUrl($parameters = [])
    {
        return 'http://consumer2.com/logout';
    }
}
?>

And define them as services.

# app/config/services.yml
services:
    acme_bundle.sso.consumer1:
        class: AcmeBundle\ServiceProviders\Consumer1
        tags:
            - { name: sso.service_provider, service: consumer1 }

    acme_bundle.sso.consumer2:
        class: AcmeBundle\ServiceProviders\Consumer2
        tags:
            - { name: sso.service_provider, service: consumer2 }

Step 4: Configure SingleSignOnIdentityProviderBundle

The bundle relies on an existing firewall to provide the actual authentication. To do this, you have to configure the single-sign-on login path to be behind that firewall, and make sure you need to be authenticated to access that route.

Add the following settings to your config.yml.

# app/config/config.yml:
krtv_single_sign_on_identity_provider:
    host:             idp.example.com
    host_scheme:      http

    login_path:       /sso/login/
    logout_path:      /sso/logout

    services:
        - consumer1
        - consumer2

    otp_parameter:    _otp
    secret_parameter: secret

Step 5: Enable route to validate OTP

# app/config/routing.yml
sso:
    resource: .
    type:     sso

Step 6: Modify security settings

# app/config/security.yml
security:
    access_control:
        # We need to allow users to access the /sso/login route
        # without being logged in
        - { path: ^/sso/login, role: IS_AUTHENTICATED_ANONYMOUSLY }

Step 7: Add / Modify login and logout success handlers

Modify your existing constructor for login and logout success handlers to include the following service:

sso_identity_provider.otp_manager

In your method used as for the succes handler, add near the end a call to the method clear() of that service.

In case you don't have a success handler for either login or logout, here's a sample implementation for it:

<?php
// src/AcmeBundle/Handler/LoginSuccessHandler.php

namespace AcmeBundle\Handler;

use Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceManager;
use Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceProviderInterface;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;

/**
 * Class LoginSuccessHandler.
 */
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
{
    /**
     * @var ServiceManager
     */
    protected $serviceManager;

    /**
     * @var UriSigner
     */
    protected $uriSigner;

    /**
     * @var SessionInterface
     */
    protected $session;

    /**
     * @var Router
     */
    protected $router;

    /**
     * @param ServiceManager $serviceManager
     * @param UriSigner $uriSigner
     * @param SessionInterface $session
     * @param Router $router
     */
    public function __construct(
        ServiceManager $serviceManager,
        UriSigner $uriSigner,
        SessionInterface $session,
        Router $router
    ) {
        $this->serviceManager = $serviceManager;
        $this->uriSigner = $uriSigner;
        $this->session = $session;
        $this->router = $router;
    }

    /**
     * @param Request $request
     * @param TokenInterface $token
     *
     * @return RedirectResponse
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token)
    {
        $redirectUrl = $this->session->get('_security.main.target_path', '/');

        if ($request->query->has('_target_path')) {
            if ($this->uriSigner->check($request->query->get('_target_path'))) {
                $redirectUrl = $request->query->get('_target_path');
            }
        }

        if (strpos($redirectUrl, '/sso/login') === false) {
            $targetService = $this->serviceManager->getSessionService();

            if ($targetService != null) {
                $redirectUrl = $this->getSsoWrappedUrl($token, $targetService, $redirectUrl);
            } else {
                $redirectUrl = $this->router->generate('_passport_dashboard_index');
            }
        }

        $this->serviceManager->clear();

        if ($request->isXmlHttpRequest()) {
            return new JsonResponse([
                'status' => true,
                'location' => $redirectUrl,
            ]);
        }

        return new RedirectResponse($redirectUrl);
    }

    /**
     * @param TokenInterface $token
     * @param string $targetService
     * @param string $redirectUrl
     *
     * @return string
     */
    protected function getSsoWrappedUrl(TokenInterface $token, $targetService, $redirectUrl)
    {
        /** @var $serviceManager ServiceProviderInterface */
        $serviceManager = $this->serviceManager->getServiceManager($targetService);
        $owner = $token->getUser();

        $wrappedSsoUrl = $this->router->generate('sso_login_path', [
            '_target_path' => $serviceManager->getOTPValidationUrl([
                '_target_path' => $redirectUrl,
            ]),
            'service' => $targetService,
        ], Router::ABSOLUTE_URL);

        return $this->uriSigner->sign($wrappedSsoUrl);
    }
}
?>
<?php
// src/AcmeBundle/Handler/LogoutSuccessHandler.php

namespace AcmeBundle\Handler;

use Krtv\Bundle\SingleSignOnIdentityProviderBundle\Manager\ServiceManager;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\UriSigner;
use Symfony\Component\Routing\Router;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Logout\LogoutSuccessHandlerInterface;

/**
* Class LogoutSuccessHandler
*/
class LogoutSuccessHandler implements LogoutSuccessHandlerInterface
{
    /**
     * @var ServiceManager;
     */
    protected $serviceManager;

    /**
     * @var UriSigner
     */
    protected $uriSigner;

    /**
     * @var SessionInterface
     */
    protected $session;

    /**
     * @var Router
     */
    protected $router;

    /**
     * Constructor
     *
     * @param ServiceManager   $serviceManager
     * @param UriSigner        $uriSigner
     * @param SessionInterface $session
     * @param Router           $router
     */
    public function __construct(
        ServiceManager $serviceManager,
        UriSigner $uriSigner,
        SessionInterface $session,
        Router $router
    ) {
        $this->serviceManager = $serviceManager;
        $this->uriSigner = $uriSigner;
        $this->session = $session;
        $this->router = $router;
    }

    /**
     * Logout success handler
     *
     * @param  Request        $request
     *
     * @return RedirectResponse|JsonResponse
     */
    public function onLogoutSuccess(Request $request)
    {
        $redirectUrl = $this->session->get('_security.main.target_path', '/');

        if ($request->query->has('_target_path')) {
            if ($this->uriSigner->check($request->query->get('_target_path'))) {
                $redirectUrl = $request->query->get('_target_path');
            }
        }

        $this->serviceManager->clear();

        if ($request->isXmlHttpRequest()) {
            return new JsonResponse([
                'status' => true,
                'location' => $redirectUrl,
            ]);
        }

        return new RedirectResponse($redirectUrl);
    }
}
?>

Define them as services

# app/config/services.yml
services:
    acme_bundle.security.login_success_handler:
        class: AcmeBundle\Handler\LoginSuccessHandler
        arguments:
            - "@sso_identity_provider.service_manager"
            - "@sso_identity_provider.uri_signer"
            - "@session"
            - "@router"

    acme_bundle.security.logout_success_handler:
        class: AcmeBundle\Handler\LogoutSuccessHandler
        arguments:
            - "@sso_identity_provider.service_manager"
            - "@sso_identity_provider.uri_signer"
            - "@session"
            - "@router"

And then finally, set the services as handlers in your firewall definition

# app/config/security.yml
security:
    firewall:
        main:
            # ...
            form_login:
                # ...
                success_handler: acme_bundle.security.login_success_handler

            logout:
                # ...
                success_handler: acme_bundle.security.logout_success_handler

Step 8: Create OTP route

In order to validate the OTP and authenticate the user, you must create a route that can retrieve the OTP details from the database and that can verify if it is valid.

The route path doesn't really matter, but take note of it. It will be used in the SP bundle. In our example, the route is /internal/v1/sso.

<?php
// src/AcmeBundle/Controller/OtpController.php

namespace AcmeBundle\Controller;

use Krtv\SingleSignOn\Model\OneTimePassword;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class OtpController extends Controller
{
    /**
     * Method used for retrieving of the OTP
     *
     * @Route("/internal/v1/sso", name="sso_otp")
     * @Method("GET")
     *
     * @param  Request $request
     *
     * @return JsonResponse
     */
    public function indexAction(Request $request)
    {
        /** @var \Krtv\SingleSignOn\Manager\OneTimePasswordManagerInterface */
        $otpManager = $this->get('sso_identity_provider.otp_manager');

        $pass = str_replace(' ', '+', $request->query->get('_otp'));

        /** @var \Krtv\SingleSignOn\Model\OneTimePasswordInterface */
        $otp = $otpManager->get($pass);

        if (!($otp instanceof OneTimePassword) || $otp->getUsed() === true) {
            throw new BadRequestHttpException('Invalid OTP password');
        }

        $response = [
            'data' => [
                'created_at' => $otp->getCreated()->format('r'),
                'hash' => $otp->getHash(),
                'password' => $otp->getPassword(),
                'is_used' => $otp->getUsed(),
            ],
        ];

        $otpManager->invalidate($otp);

        return new JsonResponse($response);
    }
}
?>

Step 9: Add redirect path to login form

In your login form, add a hidden input with the name _target_path and the value {{ app.session.get('_security.main.target_path') }} like so:

<input type="hidden" name="_target_path" value="{{ app.session.get('_security.main.target_path') }}" />

This will be used to redirect the user after login to the OTP validation route.

Step 10: Update database schema

To be able to store the OTPs, you must run the command:

php bin/console doctrine:schema:update --force

Public API of this bundle

This bundle registers several services into service container. These services will help you customize SSO flow in the your application:

That's it for Identity Provider. Now you can continue configure ServiceProvider part