Skip to content

Commit 866a8e8

Browse files
authored
Merge pull request #11 from activecollab/saml-idp-features
Saml idp features
2 parents cc2ef84 + 5eaf334 commit 866a8e8

6 files changed

+407
-3
lines changed

src/Saml/AuthnRequestResolver.php

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Active Collab Authentication project.
5+
*
6+
* (c) A51 doo <[email protected]>. All rights reserved.
7+
*/
8+
9+
namespace ActiveCollab\Authentication\Saml;
10+
11+
use Exception;
12+
use LightSaml\Binding\BindingFactory;
13+
use LightSaml\Context\Profile\MessageContext;
14+
use LightSaml\Credential\KeyHelper;
15+
use LightSaml\Credential\X509Certificate;
16+
use LightSaml\Model\Protocol\AuthnRequest;
17+
use LightSaml\Model\XmlDSig\SignatureStringReader;
18+
use Psr\Log\LoggerInterface;
19+
use Symfony\Component\HttpFoundation\Request;
20+
21+
class AuthnRequestResolver
22+
{
23+
/**
24+
* @var string
25+
*/
26+
private $saml_crt;
27+
28+
/**
29+
* @var LoggerInterface
30+
*/
31+
private $logger;
32+
33+
/**
34+
* @param string $saml_crt
35+
* @param LoggerInterface $logger
36+
*/
37+
public function __construct($saml_crt, LoggerInterface $logger = null)
38+
{
39+
$this->saml_crt = $saml_crt;
40+
$this->logger = $logger;
41+
}
42+
43+
/**
44+
* @param Request|null $request
45+
* @return AuthnRequest
46+
*/
47+
public function resolve(Request $request = null)
48+
{
49+
if (!$request) {
50+
$request = Request::createFromGlobals();
51+
}
52+
53+
$binding_factory = new BindingFactory();
54+
$binding = $binding_factory->getBindingByRequest($request);
55+
56+
$message_context = new MessageContext();
57+
$binding->receive($request, $message_context);
58+
$message = $message_context->asAuthnRequest();
59+
60+
$this->validateSignature($message);
61+
62+
return $message;
63+
}
64+
65+
/**
66+
* @param AuthnRequest $message
67+
* @throws Exception
68+
*/
69+
private function validateSignature(AuthnRequest $message)
70+
{
71+
$key = KeyHelper::createPublicKey(X509Certificate::fromFile($this->saml_crt));
72+
73+
/** @var SignatureStringReader $signature_reader */
74+
$signature_reader = $message->getSignature();
75+
76+
try {
77+
if ($signature_reader->validate($key)) {
78+
return;
79+
}
80+
81+
throw new Exception('Signature not validated');
82+
} catch (Exception $e) {
83+
if ($this->logger) {
84+
$this->logger->error("AuthnRequest validation failed with message {$e->getMessage()}.", [
85+
'exception' => $e,
86+
]);
87+
}
88+
89+
throw $e;
90+
}
91+
}
92+
}

src/Saml/SamlDataManagerInterface.php

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Active Collab Authentication project.
5+
*
6+
* (c) A51 doo <[email protected]>. All rights reserved.
7+
*/
8+
9+
namespace ActiveCollab\Authentication\Saml;
10+
11+
use LightSaml\Model\Protocol\AuthnRequest;
12+
use LightSaml\Model\Protocol\SamlMessage;
13+
14+
interface SamlDataManagerInterface
15+
{
16+
/**
17+
* @param SamlMessage $message
18+
*/
19+
public function set(SamlMessage $message);
20+
21+
/**
22+
* @param string $message_id
23+
* @return AuthnRequest|null
24+
*/
25+
public function get($message_id);
26+
27+
/**
28+
* @param string $message_id
29+
*/
30+
public function delete($message_id);
31+
}

src/Saml/SsoResponse.php

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Active Collab Authentication project.
5+
*
6+
* (c) A51 doo <[email protected]>. All rights reserved.
7+
*/
8+
9+
namespace ActiveCollab\Authentication\Saml;
10+
11+
use DateTime;
12+
use LightSaml\Binding\BindingFactory;
13+
use LightSaml\ClaimTypes;
14+
use LightSaml\Context\Profile\MessageContext;
15+
use LightSaml\Credential\KeyHelper;
16+
use LightSaml\Credential\X509Certificate;
17+
use LightSaml\Helper;
18+
use LightSaml\Model\Assertion\Assertion;
19+
use LightSaml\Model\Assertion\Attribute;
20+
use LightSaml\Model\Assertion\AttributeStatement;
21+
use LightSaml\Model\Assertion\AudienceRestriction;
22+
use LightSaml\Model\Assertion\AuthnContext;
23+
use LightSaml\Model\Assertion\AuthnStatement;
24+
use LightSaml\Model\Assertion\Conditions;
25+
use LightSaml\Model\Assertion\Issuer;
26+
use LightSaml\Model\Assertion\NameID;
27+
use LightSaml\Model\Assertion\Subject;
28+
use LightSaml\Model\Assertion\SubjectConfirmation;
29+
use LightSaml\Model\Assertion\SubjectConfirmationData;
30+
use LightSaml\Model\Protocol\Response;
31+
use LightSaml\Model\XmlDSig\SignatureWriter;
32+
use LightSaml\SamlConstants;
33+
use Psr\Log\LoggerInterface;
34+
use RuntimeException;
35+
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
36+
37+
class SsoResponse
38+
{
39+
/**
40+
* @var SamlDataManagerInterface
41+
*/
42+
private $saml_data_manager;
43+
44+
/**
45+
* @var LoggerInterface
46+
*/
47+
private $logger;
48+
49+
/**
50+
* @var string
51+
*/
52+
private $saml_crt;
53+
54+
/**
55+
* @var string
56+
*/
57+
private $saml_key;
58+
59+
/**
60+
* @param SamlDataManagerInterface $saml_data_manager
61+
* @param string $saml_crt
62+
* @param string $saml_key
63+
* @param LoggerInterface $logger
64+
*/
65+
public function __construct(SamlDataManagerInterface $saml_data_manager, $saml_crt, $saml_key, LoggerInterface $logger = null)
66+
{
67+
$this->saml_data_manager = $saml_data_manager;
68+
$this->logger = $logger;
69+
$this->saml_crt = $saml_crt;
70+
$this->saml_key = $saml_key;
71+
}
72+
73+
/**
74+
* @param string $email
75+
* @param string $message_id
76+
* @return string
77+
*/
78+
public function send($email, $message_id)
79+
{
80+
$message = $this->saml_data_manager->get($message_id);
81+
82+
if (!$message) {
83+
if ($this->logger) {
84+
$this->logger->error("Saml message with id $message_id not found or expired");
85+
}
86+
87+
throw new RuntimeException('Authentication message does not exist');
88+
}
89+
90+
$this->saml_data_manager->delete($message_id);
91+
92+
$response = new Response();
93+
$assertion = new Assertion();
94+
$response
95+
->addAssertion($assertion)
96+
->setID(Helper::generateID())
97+
->setIssueInstant(new DateTime())
98+
->setDestination($message->getAssertionConsumerServiceURL())
99+
->setIssuer(new Issuer($message->getIssuer()->getValue()));
100+
101+
$assertion
102+
->setId(Helper::generateID())
103+
->setIssueInstant(new DateTime())
104+
->setIssuer(new Issuer($message->getIssuer()->getValue()))
105+
->setSubject(
106+
(new Subject())
107+
->setNameID(new NameID($email, SamlConstants::NAME_ID_FORMAT_EMAIL))
108+
->addSubjectConfirmation(
109+
(new SubjectConfirmation())
110+
->setMethod(SamlConstants::CONFIRMATION_METHOD_BEARER)
111+
->setSubjectConfirmationData(
112+
(new SubjectConfirmationData())
113+
->setInResponseTo($message->getID())
114+
->setNotOnOrAfter(new DateTime('+1 MINUTE'))
115+
->setRecipient($message->getAssertionConsumerServiceURL())
116+
)
117+
)
118+
)
119+
->setConditions(
120+
(new Conditions())
121+
->setNotBefore(new DateTime())
122+
->setNotOnOrAfter(new DateTime('+1 MINUTE'))
123+
->addItem(
124+
new AudienceRestriction([$message->getAssertionConsumerServiceURL()])
125+
)
126+
)
127+
->addItem(
128+
(new AttributeStatement())
129+
->addAttribute(new Attribute(ClaimTypes::EMAIL_ADDRESS, $email))
130+
)
131+
->addItem(
132+
(new AuthnStatement())
133+
->setAuthnInstant(new DateTime('-10 MINUTE'))
134+
->setSessionIndex($message_id)
135+
->setAuthnContext(
136+
(new AuthnContext())->setAuthnContextClassRef(SamlConstants::AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT)
137+
)
138+
);
139+
140+
$certificate = X509Certificate::fromFile($this->saml_crt);
141+
$private_key = KeyHelper::createPrivateKey($this->saml_key, '', true);
142+
143+
$response->setSignature(new SignatureWriter($certificate, $private_key));
144+
145+
$binding_factory = new BindingFactory();
146+
$post_binding = $binding_factory->create(SamlConstants::BINDING_SAML2_HTTP_POST);
147+
$message_context = new MessageContext();
148+
$message_context->setMessage($response);
149+
/** @var SymfonyResponse $http_response */
150+
$http_response = $post_binding->send($message_context);
151+
152+
return $http_response->getContent();
153+
}
154+
}
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Active Collab Authentication project.
5+
*
6+
* (c) A51 doo <[email protected]>. All rights reserved.
7+
*/
8+
9+
namespace ActiveCollab\Authentication\Test\Saml;
10+
11+
use ActiveCollab\Authentication\Saml\AuthnRequestResolver;
12+
use ActiveCollab\Authentication\Test\TestCase\TestCase;
13+
use LightSaml\Model\Protocol\AuthnRequest;
14+
use Symfony\Component\HttpFoundation\Request;
15+
16+
class AuthnRequestResolverTest extends TestCase
17+
{
18+
/**
19+
* @var AuthnRequestResolver
20+
*/
21+
private $authn_request_resolver;
22+
23+
/**
24+
* @var array
25+
*/
26+
private $get;
27+
28+
public function setUp()
29+
{
30+
parent::setUp();
31+
32+
$this->authn_request_resolver = new AuthnRequestResolver(__DIR__ . '/../Fixtures/saml.crt');
33+
$this->get = [
34+
'SAMLRequest' => 'fZFPb8IwDMXvfIoq95KGltJGpYiNw5CYhqDbYZcpTc3I1CZdnaJ9/IV/EgeEj5af3+/Z2eyvqb0DdKiMnhI2DMgsH2Tz3u71Bn57QOu5CY1T0neaG4EKuRYNILeSb+evKz4aBrztjDXS1MRbLqbkq4Q0TCOIqrQcRZGMRTwOIQ7jSTUpIRHjeJfGQRIGO0a8j6u32+PkiD0sNVqhrWsFLPYZ89m4YAEPE86CT+ItHJTSwp5Ue2tbTmltpKj3Bi1PXFHRKnoYUVW1vnBRQFslhQXirS+gT0pXSn8/TlWeh5C/FMXaX79tC+LNEaE7Wj8bjX0D3Ra6g5Lwvlndh5mcYRjtndBHwGNakmcompqf4nbnC/Nj5zGQuJqT/L6V+8MPSIsZvVmfZ/T2n/ngHw==',
35+
'SigAlg' => 'http://www.w3.org/2000/09/xmldsig#rsa-sha1',
36+
'Signature' => 'HD0lpaP4P6zd0oiGaRfKxV0Be65ClLL7BZ1mm0LVZYm4rwg4wZY1aTlf/aq2vPa1zDJ6e5NLbt7HRde4i6PnTVLpKX0ynab2gQQ2aoYDiwBUXw+01pRXYjnCBexfRfOt57pSBqUatuDuXrxKP6bD9nZ4/9pJAtxqva/5IX6ZqiQ3AEuZ9xcZ4cD+AqRFGvUlGu0I4yZzCNeiYpka4Fr340f69Aqr7q/e8ZRyYZJZPACXCK5Iq6nhRE0hBr5ezNPQESrI2te+SRXtnOTiEufPTQi/6roFfWfvwn/DtKGN1JSbt9shzOmQcbbtEq39U1Vr0OB1Ye8Ck6vCV8cRzlZqAQ==',
37+
];
38+
}
39+
40+
public function testAuthnIsResolved()
41+
{
42+
$request = Request::create('http://localhost:8887', 'GET', $this->get);
43+
44+
$authn_request = $this->authn_request_resolver->resolve($request);
45+
46+
$this->assertInstanceOf(AuthnRequest::class, $authn_request);
47+
}
48+
49+
/**
50+
* @expectedException \LightSaml\Error\LightSamlSecurityException
51+
* @expectedExceptionMessage Unable to validate signature on query string
52+
*/
53+
public function testSamlRequestSignatureIsNotValid()
54+
{
55+
$this->get['Signature'] = 'invalid';
56+
57+
$request = Request::create('http://localhost:8887', 'GET', $this->get);
58+
59+
$this->authn_request_resolver->resolve($request);
60+
}
61+
}

test/src/Utils/SamlUtilsTest.php renamed to test/src/Saml/SamlUtilsTest.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* (c) A51 doo <[email protected]>. All rights reserved.
77
*/
88

9-
namespace ActiveCollab\Authentication\Test\Utils;
9+
namespace ActiveCollab\Shepherd\Test\Authentication;
1010

1111
use ActiveCollab\Authentication\Saml\SamlUtils;
1212
use ActiveCollab\Authentication\Test\TestCase\TestCase;
@@ -38,8 +38,8 @@ public function testAuthnRequest()
3838
'http://localhost/consumer',
3939
'http://localhost/idp',
4040
'http://localhost/issuer',
41-
file_get_contents(__DIR__.'/../Fixtures/saml.crt'),
42-
file_get_contents(__DIR__.'/../Fixtures/saml.key')
41+
file_get_contents(__DIR__ . '/../Fixtures/saml.crt'),
42+
file_get_contents(__DIR__ . '/../Fixtures/saml.key')
4343
);
4444

4545
$this->assertStringStartsWith('http://localhost/idp?SAMLRequest=', $result);

0 commit comments

Comments
 (0)