Skip to content

Commit 56191be

Browse files
committed
Add some documentaion and some light cleanup.
1 parent 3d7c1cc commit 56191be

File tree

5 files changed

+153
-18
lines changed

5 files changed

+153
-18
lines changed

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,63 @@
11
# uw-saml
22

3-
A UW-specific adapter to the python3-saml package.
3+
A UW-specific adapter to the
4+
[python3-saml](https://github.com/onelogin/python3-saml) package. This package
5+
was built to federate with other IdPs, but the default case is to use the UW
6+
Identity Provider. It can be used against any framework. For a django-specific
7+
package, also consider
8+
[uw-django-saml2](https://github.com/uw-it-aca/uw-django-saml2).
9+
10+
# Installation
11+
12+
```bash
13+
pip install uw-saml
14+
```
15+
16+
# Example login endpoint using flask
17+
18+
In this example you've gone to
19+
[SP Registry](https://iam-tools.u.washington.edu/spreg) and registered an
20+
Entity ID of https://samldemo.iamdev.s.uw.edu/saml, with an ACS endpoint of
21+
https://samldemo.iamdev.s.uw.edu/saml/login. GETs will return a
22+
redirect to the IdP for authentication, and POSTs will try to process a SAML
23+
Response.
24+
25+
```python
26+
from flask import request, session, redirect
27+
28+
@app.route('/saml/login', methods=['GET', 'POST'])
29+
def login():
30+
session.clear()
31+
args = {
32+
'entity_id': 'https://samldemo.iamdev.s.uw.edu/saml',
33+
'acs_url': 'https://samldemo.iamdev.s.uw.edu/saml/login'
34+
}
35+
if request.method == 'GET':
36+
args['return_to'] = request.args.get('url', None)
37+
return redirect(uw_saml2.login_redirect(**args))
38+
39+
attributes = uw_saml2.process_response(request.form, **args)
40+
session['userid'] = attributes['uwnetid']
41+
session['groups'] = attributes.get('groups', [])
42+
43+
relay_state = request.form.get('RelayState')
44+
if relay_state and relay_state.startswith('/'):
45+
return redirect(urljoin(request.url_root, request.form['RelayState']))
46+
47+
return 'Welcome ' + session['userid']
48+
```
49+
50+
# Considerations
51+
52+
Give some consideration to session lifetime. The session in this example lives as a
53+
signed cookie. Ideally the cookie would expire at browser close, along with
54+
some time limit appropriate for your application. An example again with flask
55+
for a ten minute limit...
56+
57+
```python
58+
from datetime import timedelta
59+
60+
app.config.update(
61+
PERMANENT_SESSION_LIFETIME=timedelta(minutes=10)
62+
)
63+
```

uw_saml2/auth.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,66 @@
11
"""UW-specific adapter for the python3-saml package."""
22
from onelogin.saml2.auth import OneLogin_Saml2_Auth
33
from .idp.uw import UwIdp
4-
from .sp import Config
4+
from .sp import Config, TWO_FACTOR_CONTEXT
55
from .idp import attribute
66
from logging import getLogger
77
logger = getLogger(__name__)
88

99

10-
def login_redirect(entity_id='', acs_url='', return_to='/', force_authn=False,
11-
idp=UwIdp, two_factor=False):
10+
def login_redirect(entity_id=None, acs_url=None, return_to='/',
11+
force_authn=False, idp=UwIdp, two_factor=False):
12+
"""
13+
Return a SAML request URL for redirecting to the Identity Provider (IdP).
14+
15+
entity_id - The Service Provider (SP) Entity ID.
16+
acs_url - The SP endpoint the Identity Provider will post back to, known
17+
technically as the Assertion Consumer Service. This endpoint along
18+
with the Entity Id are registered with the IdP.
19+
force_authn - whether to force authentication even if the user has already
20+
authenticated against the IdP.
21+
idp - which IdP to use, defaulting to UW's IdP. Other IdPs of type
22+
uw_saml2.idp.IdpConfig can be added.
23+
two_factor - whether to ask for two-factor authentication. Asking for it
24+
doesn't mean you get it back on the response. You'll need to check
25+
there as well.
26+
"""
1227
sp = Config(entity_id, acs_url)
1328
config = sp.config(idp, two_factor=two_factor)
1429
auth = OneLogin_Saml2_Auth(sp.request(), old_settings=config)
1530
return auth.login(return_to=return_to, force_authn=force_authn)
1631

1732

18-
def process_response(post, entity_id, acs_url, idp=UwIdp, two_factor=False):
33+
def process_response(post, entity_id=None, acs_url=None, idp=UwIdp,
34+
two_factor=False):
35+
"""
36+
Validate a SAML Response posted by the Identity Provider (IdP) and return
37+
its attributes as a dict.
38+
39+
post - The post data to process.
40+
entity_id - The Service Provider (SP) Entity ID.
41+
acs_url - The SP endpoint the IdP posted back to. At this point entity_id
42+
and acs_url are used to validate a SAML Response.
43+
idp - The IdP to validate against.
44+
two_factor - whether we expect a 2FA response or not. If True then
45+
non-2FA authentication will raise a SamlResponseError. Another
46+
technique would be to leave this False and check the attribute we
47+
add to the attribute data returned.
48+
"""
1949
sp = Config(entity_id, acs_url)
2050
config = sp.config(idp, two_factor=two_factor)
2151
auth = OneLogin_Saml2_Auth(sp.request(post), old_settings=config)
2252
auth.process_response()
2353
errors = auth.get_errors()
2454
if errors:
25-
raise SamlResponseException(auth.get_last_error_reason())
55+
raise SamlResponseError(auth.get_last_error_reason())
2656
attribute_data = dict(attribute.map(auth.get_attributes(), idp=idp))
57+
58+
authn_contexts = auth.get_last_authn_contexts()
59+
attribute_data['two_factor'] = TWO_FACTOR_CONTEXT in authn_contexts
60+
2761
logger.info(attribute_data)
2862
return attribute_data
2963

3064

31-
class SamlResponseException(Exception):
65+
class SamlResponseError(Exception):
3266
"""Exception to raise when a SAMLResponse can't be validated."""

uw_saml2/idp/attribute.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ def map(self, values):
2727
return values[:]
2828

2929

30+
class UWGroups(List):
31+
"""An attribute that splits out the common UW Groups prefix."""
32+
prefix = 'urn:mace:washington.edu:groups:'
33+
34+
def map(self, values):
35+
results = []
36+
for value in values:
37+
if value.startswith(self.prefix):
38+
value = value.split(self.prefix)[1]
39+
results.append(value)
40+
return results
41+
42+
3043
class NestedNameid(Attribute):
3144
"""An attribute that's an object of a NameId structure."""
3245
def map(self, values):

uw_saml2/idp/uw.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ class UwIdp(IdpConfig):
3333
'urn:oid:1.3.6.1.4.1.5923.1.1.1.6': 'eppn',
3434
'urn:oid:0.9.2342.19200300.100.1.1': 'uwnetid',
3535
'urn:oid:1.3.6.1.4.1.5923.1.1.1.1': attribute.List('affiliations'),
36-
'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': attribute.List('groups'),
36+
'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': attribute.UWGroups('groups'),
3737
'urn:oid:1.3.6.1.4.1.5923.1.1.1.9': attribute.List(
3838
'scoped_affiliations')
3939
}
40+
41+
42+
class UwIdpTwoFactor(UwIdp):
43+
two_factor = True

uw_saml2/sp.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""Service Provider configuration."""
22
from urllib.parse import urlparse
3-
CERT = ''
4-
KEY = ''
3+
import os
4+
TWO_FACTOR_CONTEXT = 'https://refeds.org/profile/mfa'
55

66

77
class Config(object):
8-
def __init__(self, entity_id, acs_url, cert=CERT, key=KEY):
9-
self.entity_id = entity_id
10-
self.acs_url = acs_url
11-
self.cert = cert
12-
self.key = key
8+
entity_id = ''
9+
acs_url = ''
10+
cert_file = ''
11+
key_file = ''
12+
_file_store = {} # cache files we've read
13+
14+
def __init__(self, entity_id=None, acs_url=None):
15+
if entity_id:
16+
self.entity_id = entity_id
17+
if acs_url:
18+
self.acs_url = acs_url
1319

1420
def request(self, post=None):
1521
post = post or {}
@@ -21,6 +27,24 @@ def request(self, post=None):
2127
'post_data': post
2228
}
2329

30+
def _read_file(self, filename):
31+
if filename in self._file_store:
32+
return self._file_store[filename]
33+
if os.path.isfile(filename):
34+
with open(filename) as fd:
35+
filedata = fd.read()
36+
self._file_store[filename] = filedata
37+
return filedata
38+
return ''
39+
40+
@property
41+
def cert(self):
42+
return self._read_file(self.cert_file)
43+
44+
@property
45+
def key(self):
46+
return self._read_file(self.key_file)
47+
2448
def config(self, idp, two_factor=False):
2549
"""Return config in a way that makes sense to OneLogin_Saml2_Auth."""
2650
data = {
@@ -43,9 +67,9 @@ def config(self, idp, two_factor=False):
4367
'privateKey': self.key
4468
}
4569
}
46-
if two_factor:
70+
if two_factor or getattr(idp, 'two_factor', False):
4771
data.update({'security': {
48-
'requestedAuthnContext': ['https://refeds.org/profile/mfa'],
72+
'requestedAuthnContext': [TWO_FACTOR_CONTEXT],
4973
'failOnAuthnContextMismatch': True
5074
}})
51-
return data
75+
return data

0 commit comments

Comments
 (0)