Merge lp:~roadmr/canonical-identity-provider/pass-custom-cert-to-django-saml2-idp into lp:canonical-identity-provider/release

Proposed by Daniel Manrique
Status: Merged
Approved by: Daniel Manrique
Approved revision: no longer in the source branch.
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: lp:~roadmr/canonical-identity-provider/pass-custom-cert-to-django-saml2-idp
Merge into: lp:canonical-identity-provider/release
Prerequisite: lp:~roadmr/canonical-identity-provider/samlconfig-certificate-field
Diff against target: 279 lines (+153/-14)
8 files modified
config-manager.txt (+1/-1)
src/identityprovider/tests/cert/custom-test-certificate.pem (+14/-0)
src/ubuntu_sso_saml/processors.py (+18/-7)
src/ubuntu_sso_saml/tests/helpers.py (+22/-2)
src/ubuntu_sso_saml/tests/test_helpers.py (+27/-0)
src/ubuntu_sso_saml/tests/test_processors.py (+33/-4)
src/ubuntu_sso_saml/tests/test_utils.py (+22/-0)
src/ubuntu_sso_saml/utils.py (+16/-0)
To merge this branch: bzr merge lp:~roadmr/canonical-identity-provider/pass-custom-cert-to-django-saml2-idp
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+334984@code.launchpad.net

Commit message

Use the SAML remote's configured certificate, if present.

This allows setting a custom certificate per RP. RPs for which this
field is empty fall back to the global certificate configured in settings.

All certificates must be generated from the global private key in settings,
which is a single setting for all RPs.

Description of the change

Use the SAML remote's configured certificate, if present.

This allows setting a custom certificate per RP. RPs for which this
field is empty fall back to the global certificate configured in settings.

All certificates must be generated from the global private key in settings,
which is a single setting for all RPs.

Note in addition to the prerequisite branch, this requires django-saml2-idp's changes from here:

https://code.launchpad.net/~roadmr/django-saml2-idp/custom-certificate-support

So once that is landed, this branch will need to be updated to point to the django-saml2-idp revision where those changes land. Tests won't pass without that :)

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) :
Revision history for this message
Daniel Manrique (roadmr) wrote :

See responses, and push with updates is coming up

Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

LGTM

review: Approve
Revision history for this message
Daniel Manrique (roadmr) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'config-manager.txt'
2--- config-manager.txt 2017-11-07 14:38:03 +0000
3+++ config-manager.txt 2017-12-11 22:58:20 +0000
4@@ -5,7 +5,7 @@
5 branches/django-openid-auth lp:~ubuntuone-pqm-team/django-openid-auth/stable;revno=107
6 branches/django-pgtools lp:django-pgtools;revno=8
7 branches/django-piston lp:~ubuntuone-pqm-team/django-piston/stable;revno=7
8-branches/django-saml2-idp lp:~ubuntuone-pqm-team/django-saml2-idp/stable;revno=69
9+branches/django-saml2-idp lp:~ubuntuone-pqm-team/django-saml2-idp/stable;revno=70
10 branches/ols-tests lp:ols-tests;tag=ols-tests-1.0.0
11 branches/ols-tests-django lp:ols-tests-django;revno=19
12 branches/python-openid lp:~ubuntuone-pqm-team/python-openid/stable;revno=1989
13
14=== added file 'src/identityprovider/tests/cert/custom-test-certificate.pem'
15--- src/identityprovider/tests/cert/custom-test-certificate.pem 1970-01-01 00:00:00 +0000
16+++ src/identityprovider/tests/cert/custom-test-certificate.pem 2017-12-11 22:58:20 +0000
17@@ -0,0 +1,14 @@
18+-----BEGIN CERTIFICATE-----
19+MIICFzCCAcGgAwIBAgIJAIsuoCD5UgkTMA0GCSqGSIb3DQEBCwUAMGcxCzAJBgNV
20+BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
21+aWRnaXRzIFB0eSBMdGQxIDAeBgNVBAMMF2N1c3RvbSB0ZXN0IGNlcnRpZmljYXRl
22+MB4XDTE3MTIwNzIzMjYxOFoXDTE4MTIwNzIzMjYxOFowZzELMAkGA1UEBhMCQVUx
23+EzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMg
24+UHR5IEx0ZDEgMB4GA1UEAwwXY3VzdG9tIHRlc3QgY2VydGlmaWNhdGUwXDANBgkq
25+hkiG9w0BAQEFAANLADBIAkEAyISI4ElrIygKGduoi46g6EMjz9kq/u0FRRjHzKqd
26+sOUB0CRzC6h9yhEJ3rKcgXjlnTjTrQUoNpinyw3GZmI+hwIDAQABo1AwTjAdBgNV
27+HQ4EFgQU0BwTxn4CBWxoCDuj5jUW+M1g4NMwHwYDVR0jBBgwFoAU0BwTxn4CBWxo
28+CDuj5jUW+M1g4NMwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAANBAAgS9Hz7
29+iPLP/a4AcOwLsz2YHIYjpMLovSXbsgjT61afsFLj2HS2oh1ucZFyfw1L8BGDa4Qx
30+myf5qJ6bRVusA4c=
31+-----END CERTIFICATE-----
32
33=== modified file 'src/ubuntu_sso_saml/processors.py'
34--- src/ubuntu_sso_saml/processors.py 2017-12-04 16:21:42 +0000
35+++ src/ubuntu_sso_saml/processors.py 2017-12-11 22:58:20 +0000
36@@ -10,7 +10,10 @@
37 from saml2idp.exceptions import CannotHandleAssertion
38
39 from identityprovider.models import OpenIDRPConfig
40-from ubuntu_sso_saml.utils import get_config_for_acs
41+from ubuntu_sso_saml.utils import (
42+ certificate_from_sp_config,
43+ get_config_for_acs
44+)
45
46
47 __all__ = [
48@@ -321,11 +324,12 @@
49 attrs = json.loads(sp_config.attributes)
50 else:
51 attrs = {}
52+ certificate = certificate_from_sp_config(sp_config)
53 self._assertion_params['ATTRIBUTES'] = self._eval_attributes(attrs)
54 if sp_config.audience:
55- self._format_assertion_restricted()
56+ self._format_assertion_restricted(certificate=certificate)
57 else:
58- self._format_assertion_unrestricted()
59+ self._format_assertion_unrestricted(certificate=certificate)
60
61 def _validate_request(self):
62 acs_url = self._request_params['ACS_URL']
63@@ -366,13 +370,20 @@
64 def _decode_request_compressed(self):
65 return codex.decode_base64_and_inflate(self._saml_request)
66
67- def _format_assertion_unrestricted(self):
68+ def _format_assertion_unrestricted(self, certificate=None):
69 self._assertion_xml = xml_render.get_assertion_googleapps_xml(
70- self._assertion_params, signed=True)
71+ self._assertion_params, signed=True, certificate=certificate)
72
73- def _format_assertion_restricted(self):
74+ def _format_assertion_restricted(self, certificate=None):
75 self._assertion_xml = xml_render.get_assertion_salesforce_xml(
76- self._assertion_params, signed=True)
77+ self._assertion_params, signed=True, certificate=certificate)
78+
79+ def _format_response(self):
80+ sp_config = self.get_config()
81+ # Helper properly handles sp_config being None.
82+ certificate = certificate_from_sp_config(sp_config)
83+ super(CanonicalProcessor, self)._format_response(
84+ certificate=certificate)
85
86 def _eval_attributes(self, attributes):
87 for key, value in attributes.items():
88
89=== modified file 'src/ubuntu_sso_saml/tests/helpers.py'
90--- src/ubuntu_sso_saml/tests/helpers.py 2017-05-11 15:22:36 +0000
91+++ src/ubuntu_sso_saml/tests/helpers.py 2017-12-11 22:58:20 +0000
92@@ -1,8 +1,28 @@
93 from identityprovider import tests
94
95
96-def find_cert_path():
97+def find_cert_path(filename=None):
98 """
99 Find the right path to certificates
100 """
101- return tests.__path__[0] + '/cert/'
102+ if filename:
103+ return tests.__path__[0] + '/cert/' + filename
104+ else:
105+ return tests.__path__[0] + '/cert/'
106+
107+
108+def cert_string_from_file(cert_filename):
109+ """
110+ Returns a string with contents of a PEM-format certificate read from file.
111+
112+ Returns only the "meat" of the certificate, just the base64-encoded content
113+ without the BEGIN/END delimiter lines, all in a single-line string.
114+ """
115+ with open(cert_filename) as cert_file:
116+ cert_lines = cert_file.readlines()
117+
118+ delimiters = ["-----BEGIN CERTIFICATE-----\n",
119+ "-----END CERTIFICATE-----\n"]
120+ cert_lines = [l.strip() for l in cert_lines if l not in delimiters]
121+
122+ return "".join(cert_lines)
123
124=== added file 'src/ubuntu_sso_saml/tests/test_helpers.py'
125--- src/ubuntu_sso_saml/tests/test_helpers.py 1970-01-01 00:00:00 +0000
126+++ src/ubuntu_sso_saml/tests/test_helpers.py 2017-12-11 22:58:20 +0000
127@@ -0,0 +1,27 @@
128+"""Tests for helper methods."""
129+from unittest import TestCase
130+from ubuntu_sso_saml.tests.helpers import (
131+ cert_string_from_file,
132+ find_cert_path,
133+)
134+
135+
136+class CertStringHelpersTestCase(TestCase):
137+
138+ def setUp(self, *args, **kwargs):
139+ super(CertStringHelpersTestCase, self).setUp(*args, **kwargs)
140+ self.test_cert_file = find_cert_path("certificate.pem")
141+
142+ def test_from_file(self):
143+ # Build the "meat" of the certificate for verification with
144+ # a slightly different algorithm than cert_string_from_file
145+ # (otherwise this test would be a tautology)
146+ expected = ""
147+ with open(self.test_cert_file) as cert_file:
148+ for line in cert_file:
149+ if "CERTIFICATE-----" not in line:
150+ expected = expected + line.strip()
151+
152+ cs = cert_string_from_file(self.test_cert_file)
153+
154+ self.assertEqual(expected, cs)
155
156=== modified file 'src/ubuntu_sso_saml/tests/test_processors.py'
157--- src/ubuntu_sso_saml/tests/test_processors.py 2017-12-04 16:21:42 +0000
158+++ src/ubuntu_sso_saml/tests/test_processors.py 2017-12-11 22:58:20 +0000
159@@ -35,11 +35,13 @@
160 from identityprovider.tests import DEFAULT_USER_PASSWORD
161 from identityprovider.tests.utils import AuthenticatedTestCase
162 from ubuntu_sso_saml.models import SAMLConfig
163-from ubuntu_sso_saml.tests.helpers import find_cert_path
164+from ubuntu_sso_saml.tests.helpers import cert_string_from_file, find_cert_path
165
166
167 SAML_TEAM = 'saml2team' # Name of team enabled for SAML 2.0
168
169+CUSTOM_TEST_CERTIFICATE = "custom-test-certificate.pem"
170+
171
172 def get_saml_response(response):
173 tree = PyQuery(response.content)
174@@ -86,9 +88,8 @@
175 new_saml2idp_config = copy.deepcopy(saml2idp_metadata.SAML2IDP_CONFIG)
176 self.patch(saml2idp_metadata, 'SAML2IDP_CONFIG',
177 new=new_saml2idp_config)
178- path = find_cert_path()
179- cert_file = path + 'certificate.pem'
180- priv_file = path + 'private-key.pem'
181+ cert_file = find_cert_path('certificate.pem')
182+ priv_file = find_cert_path('private-key.pem')
183 test_config = {
184 'certificate_file': cert_file,
185 'private_key_file': priv_file,
186@@ -1604,3 +1605,31 @@
187 # I should be sent to the login page
188 query_params = urllib.urlencode({'next': "/saml/process"})
189 self.assertRedirects(response, reverse('login') + "?" + query_params)
190+
191+ def test_default_certificate_in_assertion(self):
192+ self.setup_saml_sp()
193+
194+ data = self.get_request_data()
195+ response = self.client.get('/+saml', data=data, follow=True)
196+ samlresponse = get_saml_response(response)
197+
198+ cert_filename = saml2idp_metadata.SAML2IDP_CONFIG['certificate_file']
199+
200+ self.assertIn(cert_string_from_file(cert_filename), samlresponse)
201+
202+ def test_custom_certificate_in_assertion(self):
203+ cert_filename = saml2idp_metadata.SAML2IDP_CONFIG['certificate_file']
204+ custom_cert_filename = find_cert_path(CUSTOM_TEST_CERTIFICATE)
205+ with open(custom_cert_filename) as custom_cert_file:
206+ custom_cert_string = custom_cert_file.read()
207+ # Setup the SP with the delimited certificate
208+ self.setup_saml_sp(certificate=custom_cert_string)
209+ # Keep the non-delimited certificate for asserting later.
210+ custom_cert_string = cert_string_from_file(custom_cert_filename)
211+
212+ data = self.get_request_data()
213+ response = self.client.get('/+saml', data=data, follow=True)
214+ samlresponse = get_saml_response(response)
215+ # Ensure the assertion contains the custom cert, not the default one.
216+ self.assertIn(custom_cert_string, samlresponse)
217+ self.assertNotIn(cert_string_from_file(cert_filename), samlresponse)
218
219=== modified file 'src/ubuntu_sso_saml/tests/test_utils.py'
220--- src/ubuntu_sso_saml/tests/test_utils.py 2017-05-12 15:39:10 +0000
221+++ src/ubuntu_sso_saml/tests/test_utils.py 2017-12-11 22:58:20 +0000
222@@ -3,7 +3,9 @@
223 from django.test import TestCase
224
225 from ubuntu_sso_saml.models import SAMLConfig
226+from ubuntu_sso_saml.tests.helpers import find_cert_path
227 from ubuntu_sso_saml.utils import (
228+ certificate_from_sp_config,
229 get_config_for_acs,
230 get_config_and_link_for_resource,
231 get_deeplink_url,
232@@ -75,3 +77,23 @@
233
234 url = get_deeplink_url(resource_name, (resource_pattern, url_pattern))
235 self.assertEqual(url, 'http://example.com/foo/bar-baz')
236+
237+
238+class CertificateFromSpConfigTestCase(TestCase):
239+
240+ def test_certificate_from_no_sp_config(self):
241+ self.assertEqual(None, certificate_from_sp_config(None))
242+
243+ def test_certificate_from_sp_config_no_cert(self):
244+ sp_config = SAMLConfig.objects.create(
245+ acs_url='http://localhost')
246+ self.assertEqual(None, certificate_from_sp_config(sp_config))
247+
248+ def test_certificate_from_sp_config_with_cert(self):
249+ with open(find_cert_path('certificate.pem')) as cert_file:
250+ cert_string = cert_file.read()
251+ sp_config = SAMLConfig.objects.create(
252+ acs_url='http://localhost', certificate=cert_string)
253+ # Ensure it's identical to a what we read from the file, and not
254+ # a djangoized unicode.
255+ self.assertEqual(cert_string, certificate_from_sp_config(sp_config))
256
257=== modified file 'src/ubuntu_sso_saml/utils.py'
258--- src/ubuntu_sso_saml/utils.py 2017-05-17 13:16:46 +0000
259+++ src/ubuntu_sso_saml/utils.py 2017-12-11 22:58:20 +0000
260@@ -51,3 +51,19 @@
261 if match is not None:
262 url = url_pattern % match.groupdict()
263 return url
264+
265+
266+def certificate_from_sp_config(sp_config):
267+ """
268+ Return the SP's certificate (bytes) or None if there's no certificate.
269+
270+ Also returns None if the passed sp_config is None.
271+
272+ """
273+ if sp_config and sp_config.certificate:
274+ # Django stores string/unicode but M2Crypto needs bytes for
275+ # certificate values.
276+ certificate = sp_config.certificate.encode('ascii')
277+ else:
278+ certificate = None
279+ return certificate