Skip to content

Commit 9854699

Browse files
author
Christian Pedersen
committed
First release
1 parent d585325 commit 9854699

15 files changed

Lines changed: 1586 additions & 0 deletions

LICENSE

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Copyright (c) 2011, OneLogin, Inc.
2+
All rights reserved.
3+
4+
Redistribution and use in source and binary forms, with or without
5+
modification, are permitted provided that the following conditions are met:
6+
* Redistributions of source code must retain the above copyright
7+
notice, this list of conditions and the following disclaimer.
8+
* Redistributions in binary form must reproduce the above copyright
9+
notice, this list of conditions and the following disclaimer in the
10+
documentation and/or other materials provided with the distribution.
11+
* Neither the name of the <organization> nor the
12+
names of its contributors may be used to endorse or promote products
13+
derived from this software without specific prior written permission.
14+
15+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18+
DISCLAIMED. IN NO EVENT SHALL ONELOGIN, INC. BE LIABLE FOR ANY
19+
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
20+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
21+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
22+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
===========
2+
Python SAML
3+
===========
4+
5+
This Python SAML toolkit provides functionality for creating SAML AuthnRequests
6+
which can be sent to an identity provider and for verifying SAML Responses from
7+
an identity provider. Onelogin is an example of an identity provider.
8+
9+
Non-Python Requirements
10+
=======================
11+
12+
- libxml2
13+
- libxslt
14+
- libxmlsec1
15+
- libxml2-dev
16+
- libxslt-dev
17+
- libxmlsec1-dev
18+
19+
This toolkit makes use of the xmlsec binary in order to verify the response
20+
signature. Make sure you add the xmlsec binary to your Windows or Linux PATH.
21+
Unfortunately, the python bindings for libxml2, which xmlsec uses, do not
22+
provide the functionality needed to programmatically verify the signature,
23+
specifically the libxml2.xmlAddID function.
24+
For more information see http://www.aleksey.com/xmlsec/faq.html Section 3.2
25+
26+
Python Requirements
27+
===================
28+
29+
- python-setuptools
30+
- python2.6
31+
- python2.6-dev
32+
- lxml 2.3 or greater
33+
34+
Test Requirements
35+
=================
36+
37+
- fudge 0.9.5 or greater
38+
- nose 0.10.4 or greater
39+
40+
Installing
41+
==========
42+
To install in the default Python library location::
43+
44+
python setup.py install
45+
46+
To install in a custom Python library location in Linux::
47+
48+
MY_BASE_DIR=/home/foouser
49+
export PYTHONPATH=$MY_BASE_DIR/lib/python2.6/site-packages
50+
python setup.py install --prefix=$MY_BASE_DIR
51+
52+
Testing
53+
=======
54+
To run the unit-tests::
55+
56+
python setup.py test
57+
58+
Running the example app
59+
=======================
60+
To run with the default configuration file, example.cfg, which needs to be
61+
filled out completely::
62+
63+
python setup.py example
64+
65+
To run with your own configuration file in Linux::
66+
67+
python setup.py example --config-file=~/my_config.cfg
68+
69+
Example app breakdown
70+
=====================
71+
The example app subclasses the BaseHTTPRequestHandler in order to process
72+
GET and POST HTTP requests. It is by no means a secure application and should
73+
not be used for anything other than exploring and testing.
74+
75+
Creating and sending the AuthnRequest to the identity provider
76+
--------------------------------------------------------------
77+
The identification flow starts when a user requests a resource from the service
78+
provider which, in this case, is implemented in our example app. In our example,
79+
the user requests a resource by going to the root path of our app, i.e,::
80+
81+
http://localhost:7070/
82+
83+
Our example app receives this request and in turn creates a SAML AuthnRequest
84+
in the form of URL string which we use to redirect the user to our identity
85+
provider--OneLogin::
86+
87+
from BaseHTTPServer import BaseHTTPRequestHandler
88+
from onelogin.saml import AuthRequest
89+
...
90+
class SampleAppHTTPRequestHandler(BaseHTTPRequestHandler):
91+
...
92+
def do_GET(self):
93+
...
94+
url = AuthRequest.create(**self.settings)
95+
self.send_response(301)
96+
self.send_header("Location", url)
97+
self.end_headers()
98+
99+
The self.settings variable is a dictionary with the following entries. These
100+
entries are originally retrieved from the configuration file passed in as a
101+
command line option to the example app::
102+
103+
settings = dict(
104+
assertion_consumer_service_url='http://localhost:7070/example/saml/consume',
105+
issuer='python-saml',
106+
name_identifier_format='urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
107+
idp_sso_target_url='https://app.onelogin.com/saml/signon/<id>',
108+
)
109+
110+
The idp_sso_target_url is the SAML Login URL from the SAML Test (IdP) app. You
111+
must register with OneLogin and add the SAML Test (IdP) app to your apps in
112+
order to get the idp_sso_target_url. You must also register the
113+
assertion_consumer_service_url with the SAML Test (IdP) app by entering it in
114+
the SAML Consumer URL field.
115+
116+
Receiving and verifying the response
117+
------------------------------------
118+
The user will then be redirected to the OneLogin login page where they will
119+
enter their credentials in order to verify their identity. After Onelogin has
120+
verified their identity, it will redirect the user to the
121+
assertion_consumer_service_url--http://localhost:7070/example/saml/consume.
122+
123+
Our example app then verifies the SAML Response from OneLogin using the fingerprint
124+
of the public certificate originally obtained from OneLogin::
125+
126+
def do_POST(self):
127+
...
128+
length = int(self.headers['Content-Length'])
129+
data = self.rfile.read(length)
130+
query = urlparse.parse_qs(data)
131+
res = Response(
132+
query['SAMLResponse'].pop(),
133+
self.settings['idp_cert_fingerprint'],
134+
)
135+
valid = res.is_valid()
136+
name_id = res.name_id
137+
if valid:
138+
msg = 'The identify of {name_id} has been verified'.format(
139+
name_id=name_id,
140+
)
141+
self._serve_msg(200, msg)
142+
else:
143+
msg = '{name_id} is not authorized to use this resource'.format(
144+
name_id=name_id,
145+
)
146+
self._serve_msg(401, msg)
147+
148+
Once again, the self.settings variable is populated from an entry in
149+
the configuration file. You can find the public certificate under Security->SAML
150+
after you login to OneLogin.
151+
152+
For full details see **example.py** and **example.cfg**.

example.cfg

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[app]
2+
host = localhost
3+
port = 7070
4+
5+
[saml]
6+
issuer = python-saml
7+
# Email address is a common format to use when verifying identity
8+
name_identifier_format = urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
9+
# The SAML Consumer URL from your OneLogin app
10+
assertion_consumer_service_url =
11+
# The SAML Login URL from your OneLogin app
12+
idp_sso_target_url =
13+
# The x.509 certificate fingerprint from OneLogin, found under Security->SAML,
14+
# as a file path. Only one of idp_cert_file and idp_cert_fingerprint will be
15+
# used by the example app, with idp_cert_file being having priority if both are
16+
# defined.
17+
idp_cert_file =
18+
# The x.509 certificate fingerprint from OneLogin, found under Security->SAML,
19+
# as a string. Only one of idp_cert_file and idp_cert_fingerprint will be
20+
# used by the example app, with idp_cert_file being having priority if both are
21+
# defined.
22+
idp_cert_fingerprint =

example.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import os
2+
import ConfigParser
3+
import optparse
4+
import logging
5+
import shutil
6+
import urlparse
7+
8+
from StringIO import StringIO
9+
from BaseHTTPServer import BaseHTTPRequestHandler
10+
from BaseHTTPServer import HTTPServer
11+
12+
from onelogin.saml import AuthRequest, Response
13+
14+
__version__ = '0.1'
15+
16+
log = logging.getLogger(__name__)
17+
18+
class SampleAppHTTPRequestHandler(BaseHTTPRequestHandler):
19+
server_version = 'SampleAppHTTPRequestHandler/%s' % __version__
20+
21+
def _serve_msg(self, code, msg):
22+
f = StringIO()
23+
f.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">')
24+
f.write("<html>\n<body>\n%s</body>\n</html>\n" % msg)
25+
length = f.tell()
26+
f.seek(0)
27+
28+
if code == 200:
29+
self.send_response(code)
30+
else:
31+
self.send_error(code, message=msg)
32+
33+
self.send_header('Content-type', 'text/html')
34+
self.send_header('Content-Length', str(length))
35+
self.end_headers()
36+
37+
shutil.copyfileobj(f, self.wfile)
38+
39+
def _bad_request(self):
40+
"""Serve Bad Request (400)."""
41+
self._serve_msg(400, 'Bad Request')
42+
43+
def log_message(self, format, *args):
44+
log.info(format % args)
45+
46+
def do_HEAD(self):
47+
"""Serve a HEAD request."""
48+
self._bad_request()
49+
50+
def do_DEL(self):
51+
"""Serve a DEL request."""
52+
self._bad_request()
53+
54+
def do_GET(self):
55+
"""Serve a GET request."""
56+
if not self.path == '/':
57+
self._bad_request()
58+
return
59+
60+
url = AuthRequest.create(**self.settings)
61+
self.send_response(301)
62+
self.send_header("Location", url)
63+
self.end_headers()
64+
65+
def do_POST(self):
66+
"""Serve a POST request."""
67+
if not self.path == self.saml_post_path:
68+
self._bad_request()
69+
return
70+
71+
length = int(self.headers['Content-Length'])
72+
data = self.rfile.read(length)
73+
query = urlparse.parse_qs(data)
74+
res = Response(
75+
query['SAMLResponse'].pop(),
76+
self.settings['idp_cert_fingerprint'],
77+
)
78+
valid = res.is_valid()
79+
name_id = res.name_id
80+
if valid:
81+
msg = 'The identify of {name_id} has been verified'.format(
82+
name_id=name_id,
83+
)
84+
self._serve_msg(200, msg)
85+
else:
86+
msg = '{name_id} is not authorized to use this resource'.format(
87+
name_id=name_id,
88+
)
89+
self._serve_msg(401, msg)
90+
91+
def main(config_file):
92+
logging.basicConfig(
93+
level=logging.INFO,
94+
format='%(asctime)s.%(msecs)03d example: %(levelname)s: %(message)s',
95+
datefmt='%Y-%m-%dT%H:%M:%S',
96+
)
97+
98+
config = ConfigParser.RawConfigParser()
99+
config_path = os.path.expanduser(config_file)
100+
config_path = os.path.abspath(config_path)
101+
with open(config_path) as f:
102+
config.readfp(f)
103+
104+
host = config.get('app', 'host')
105+
port = config.get('app', 'port')
106+
port = int(port)
107+
108+
settings = dict()
109+
settings['assertion_consumer_service_url'] = config.get(
110+
'saml',
111+
'assertion_consumer_service_url'
112+
)
113+
settings['issuer'] = config.get(
114+
'saml',
115+
'issuer'
116+
)
117+
settings['name_identifier_format'] = config.get(
118+
'saml',
119+
'name_identifier_format'
120+
)
121+
settings['idp_sso_target_url'] = config.get(
122+
'saml',
123+
'idp_sso_target_url'
124+
)
125+
settings['idp_cert_file'] = config.get(
126+
'saml',
127+
'idp_cert_file'
128+
)
129+
settings['idp_cert_fingerprint'] = config.get(
130+
'saml',
131+
'idp_cert_fingerprint'
132+
)
133+
134+
cert_file = settings.pop('idp_cert_file', None)
135+
136+
# idp_cert_file has priority over idp_cert_fingerprint
137+
if cert_file:
138+
cert_path = os.path.expanduser(cert_file)
139+
cert_path = os.path.abspath(cert_path)
140+
141+
with open(cert_path) as f:
142+
settings['idp_cert_fingerprint'] = f.read()
143+
144+
parts = urlparse.urlparse(settings['assertion_consumer_service_url'])
145+
SampleAppHTTPRequestHandler.protocol_version = 'HTTP/1.0'
146+
SampleAppHTTPRequestHandler.settings = settings
147+
SampleAppHTTPRequestHandler.saml_post_path = parts.path
148+
httpd = HTTPServer(
149+
(host, port),
150+
SampleAppHTTPRequestHandler,
151+
)
152+
153+
socket_name = httpd.socket.getsockname()
154+
155+
log.info(
156+
'Serving HTTP on {host} port {port} ...'.format(
157+
host=socket_name[0],
158+
port=socket_name[1],
159+
)
160+
)
161+
162+
httpd.serve_forever()
163+
164+
if __name__ == '__main__':
165+
parser = optparse.OptionParser(
166+
usage='%prog [OPTS]',
167+
)
168+
parser.add_option(
169+
'--config-file',
170+
metavar='PATH',
171+
help='The configuration file containing the app and SAML settings',
172+
)
173+
174+
parser.set_defaults(
175+
config_file='example.cfg'
176+
)
177+
178+
options, args = parser.parse_args()
179+
if args:
180+
parser.error('Wrong number of arguments')
181+
182+
main(options.config_file)

onelogin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__import__('pkg_resources').declare_namespace(__name__)

0 commit comments

Comments
 (0)