From ee4dcfb732de1391976e858a38737a713b4b0e70 Mon Sep 17 00:00:00 2001
From: John Maguire <jmaguire@duosecurity.com>
Date: Wed, 31 May 2017 17:34:57 -0400
Subject: [PATCH] Add IdP support for ECP profile

---
 docs/simplesamlphp-ecp-idp.txt                |  9 ++++
 modules/core/lib/Auth/UserPassBase.php        | 25 +++++++--
 modules/saml/lib/IdP/SAML2.php                | 20 ++++++-
 .../core/lib/Auth/UserPassBaseTest.php        | 52 +++++++++++++++++++
 tests/modules/saml/lib/IdP/SAML2Test.php      | 38 ++++++++++++++
 www/saml2/idp/metadata.php                    |  8 +++
 6 files changed, 147 insertions(+), 5 deletions(-)
 create mode 100644 tests/modules/core/lib/Auth/UserPassBaseTest.php
 create mode 100644 tests/modules/saml/lib/IdP/SAML2Test.php

diff --git a/docs/simplesamlphp-ecp-idp.txt b/docs/simplesamlphp-ecp-idp.txt
index 5a03be055..28ac7f90a 100644
--- a/docs/simplesamlphp-ecp-idp.txt
+++ b/docs/simplesamlphp-ecp-idp.txt
@@ -5,6 +5,15 @@ This document describes the necessary steps to enable support for the [SAML V2.0
 
 The SAML V2.0 Enhanced Client or Proxy (ECP) profile is a SSO profile for use with HTTP, and clients with the capability to directly contact a principal's identity provider(s) without requiring discovery and redirection by the service provider, as in the case of a browser. It is particularly useful for desktop or server-side HTTP clients.
 
+Limitations
+-----------
+* Authentication must be done via [HTTP Basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme).
+* Authentication must not require user interaction (e.g. auth filters that require user input).
+* Channel Bindings are unsupported.
+* "Holder of Key" Subject Confirmation is unsupported.
+
+This feature has been tested to work with Microsoft Office 365, but other service providers may require features of the ECP profile that are currently unsupported!
+
 Enabling ECP Profile on the IdP
 -----------------------------------
 
diff --git a/modules/core/lib/Auth/UserPassBase.php b/modules/core/lib/Auth/UserPassBase.php
index e688a70dd..8ae524eec 100644
--- a/modules/core/lib/Auth/UserPassBase.php
+++ b/modules/core/lib/Auth/UserPassBase.php
@@ -31,9 +31,9 @@ abstract class sspmod_core_Auth_UserPassBase extends SimpleSAML_Auth_Source {
 	 * If this is NULL, we won't force any username.
 	 */
 	private $forcedUsername;
-	
+
 	/**
-	 * Links to pages from login page. 
+	 * Links to pages from login page.
 	 * From configuration
 	 */
 	protected $loginLinks;
@@ -84,7 +84,7 @@ abstract class sspmod_core_Auth_UserPassBase extends SimpleSAML_Auth_Source {
 	public function __construct($info, &$config) {
 		assert('is_array($info)');
 		assert('is_array($config)');
-		
+
 		if (isset($config['core:loginpage_links'])) {
 			$this->loginLinks = $config['core:loginpage_links'];
 		}
@@ -183,7 +183,24 @@ abstract class sspmod_core_Auth_UserPassBase extends SimpleSAML_Auth_Source {
 			 * is allowed to change the username.
 			 */
 			$state['forcedUsername'] = $this->forcedUsername;
-		}
+	    }
+
+	  // ECP requests supply authentication credentials with the AUthnRequest
+	  // so we validate them now rather than redirecting
+	  if (isset($state['core:auth:username']) && isset($state['core:auth:password'])) {
+	      $username = $state['core:auth:username'];
+	      $password = $state['core:auth:password'];
+
+	      if (isset($state['forcedUsername'])) {
+	          $username = $state['forcedUsername'];
+	      }
+
+	      $attributes = $this->login($username, $password);
+	      assert('is_array($attributes)');
+	      $state['Attributes'] = $attributes;
+
+	      return;
+	  }
 
 		/* Save the $state-array, so that we can restore it after a redirect. */
 		$id = SimpleSAML_Auth_State::saveState($state, self::STAGEID);
diff --git a/modules/saml/lib/IdP/SAML2.php b/modules/saml/lib/IdP/SAML2.php
index 158cdd21b..ebb8354f9 100644
--- a/modules/saml/lib/IdP/SAML2.php
+++ b/modules/saml/lib/IdP/SAML2.php
@@ -1,6 +1,5 @@
 <?php
 
-
 use RobRichards\XMLSecLibs\XMLSecurityKey;
 
 /**
@@ -258,6 +257,9 @@ class sspmod_saml_IdP_SAML2
         if ($idpMetadata->getBoolean('saml20.hok.assertion', false)) {
             $supportedBindings[] = \SAML2\Constants::BINDING_HOK_SSO;
         }
+        if ($idpMetadata->getBoolean('saml20.ecp', false)) {
+            $supportedBindings[] = \SAML2\Constants::BINDING_PAOS;
+        }
 
         if (isset($_REQUEST['spentityid'])) {
             /* IdP initiated authentication. */
@@ -428,9 +430,25 @@ class sspmod_saml_IdP_SAML2
             'saml:RequestedAuthnContext'  => $authnContext,
         );
 
+        // ECP AuthnRequests need to supply credentials
+        if ($binding instanceof SOAP) {
+            self::processSOAPAuthnRequest($state);
+        }
+
         $idp->handleAuthenticationRequest($state);
     }
 
+    public static function processSOAPAuthnRequest(array &$state)
+    {
+        if (!isset($_SERVER['PHP_AUTH_USER']) || !isset($_SERVER['PHP_AUTH_PW'])) {
+            SimpleSAML_Logger::error("ECP AuthnRequest did not contain Basic Authentication header");
+            // TODO Throw some sort of ECP-specific exception / convert this to SOAP fault
+            throw new SimpleSAML_Error_Error("WRONGUSERPASS");
+        }
+
+        $state['core:auth:username'] = $_SERVER['PHP_AUTH_USER'];
+        $state['core:auth:password'] = $_SERVER['PHP_AUTH_PW'];
+    }
 
     /**
      * Send a logout request to a given association.
diff --git a/tests/modules/core/lib/Auth/UserPassBaseTest.php b/tests/modules/core/lib/Auth/UserPassBaseTest.php
new file mode 100644
index 000000000..ff248bdc5
--- /dev/null
+++ b/tests/modules/core/lib/Auth/UserPassBaseTest.php
@@ -0,0 +1,52 @@
+<?php
+
+class sspmod_core_Auth_UserPassBaseTest extends \PHPUnit_Framework_TestCase
+{
+    public function testAuthenticateECPCallsLoginAndSetsAttributes()
+    {
+        $state = array();
+        $attributes = array('attrib' => 'val');
+
+        $username = $state['core:auth:username'] = 'username';
+        $password = $state['core:auth:password'] = 'password';
+
+        $stub = $this->getMockBuilder('sspmod_core_Auth_UserPassBase')
+            ->disableOriginalConstructor()
+            ->setMethods(array('login'))
+            ->getMockForAbstractClass();
+
+        $stub->expects($this->once())
+            ->method('login')
+            ->with($username, $password)
+            ->will($this->returnValue($attributes));
+
+        $stub->authenticate($state);
+
+        $this->assertSame($attributes, $state['Attributes']);
+    }
+
+    public function testAuthenticateECPCallsLoginWithForcedUsername()
+    {
+        $state = array();
+        $attributes = array();
+
+        $forcedUsername = 'forcedUsername';
+
+        $state['core:auth:username'] = 'username';
+        $password = $state['core:auth:password'] = 'password';
+
+        $stub = $this->getMockBuilder('sspmod_core_Auth_UserPassBase')
+            ->disableOriginalConstructor()
+            ->setMethods(array('login'))
+            ->getMockForAbstractClass();
+
+        $stub->expects($this->once())
+            ->method('login')
+            ->with($forcedUsername, $password)
+            ->will($this->returnValue($attributes));
+
+        $stub->setForcedUsername($forcedUsername);
+
+        $stub->authenticate($state);
+    }
+}
diff --git a/tests/modules/saml/lib/IdP/SAML2Test.php b/tests/modules/saml/lib/IdP/SAML2Test.php
new file mode 100644
index 000000000..4ca209592
--- /dev/null
+++ b/tests/modules/saml/lib/IdP/SAML2Test.php
@@ -0,0 +1,38 @@
+<?php
+
+class sspmod_saml_IdP_SAML2Test extends \PHPUnit_Framework_TestCase
+{
+    public function testProcessSOAPAuthnRequest()
+    {
+        $username = $_SERVER['PHP_AUTH_USER'] = 'username';
+        $password = $_SERVER['PHP_AUTH_PW'] = 'password';
+        $state = array();
+
+        sspmod_saml_IdP_SAML2::processSOAPAuthnRequest($state);
+
+        $this->assertEquals($username, $state['core:auth:username']);
+        $this->assertEquals($password, $state['core:auth:password']);
+    }
+
+    public function testProcessSOAPAuthnRequestMissingUsername()
+    {
+        $this->setExpectedException('SimpleSAML_Error_Error', 'WRONGUSERPASS');
+
+        $_SERVER['PHP_AUTH_PW'] = 'password';
+        unset($_SERVER['PHP_AUTH_USER']);
+        $state = array();
+
+        sspmod_saml_IdP_SAML2::processSOAPAuthnRequest($state);
+    }
+
+    public function testProcessSOAPAuthnRequestMissingPassword()
+    {
+        $this->setExpectedException('SimpleSAML_Error_Error', 'WRONGUSERPASS');
+
+        $_SERVER['PHP_AUTH_USER'] = 'username';
+        unset($_SERVER['PHP_AUTH_PW']);
+        $state = array();
+
+        sspmod_saml_IdP_SAML2::processSOAPAuthnRequest($state);
+    }
+}
diff --git a/www/saml2/idp/metadata.php b/www/saml2/idp/metadata.php
index 897f22d31..5a7e98942 100644
--- a/www/saml2/idp/metadata.php
+++ b/www/saml2/idp/metadata.php
@@ -127,6 +127,14 @@ try {
         ));
     }
 
+    if ($idpmeta->getBoolean('saml20.ecp', false)) {
+        $metaArray['SingleSignOnService'][] = array(
+            'index' => 0,
+            'Binding' => SAML2_Const::BINDING_SOAP,
+            'Location' => SimpleSAML_Utilities::getHostnameURL() . 'saml2/idp/SSOService.php',
+        );
+    }
+
     $metaArray['NameIDFormat'] = $idpmeta->getString(
         'NameIDFormat',
         'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
-- 
GitLab