Skip to content

Commit 6a61954

Browse files
committed
Add replay attack protection.
1 parent f3b0bcc commit 6a61954

File tree

5 files changed

+49
-1
lines changed

5 files changed

+49
-1
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def login():
5959

6060
## Considerations
6161

62+
### Sessions
63+
6264
Give some consideration to session lifetime. The session in this example lives as a
6365
signed cookie. Ideally the cookie would expire at browser close, along with
6466
some time limit appropriate for your application. An example again with flask
@@ -71,3 +73,18 @@ app.config.update(
7173
PERMANENT_SESSION_LIFETIME=timedelta(minutes=10)
7274
)
7375
```
76+
77+
### Replay attack prevention
78+
79+
By default this package uses an in-memory cache to check for replay attacks.
80+
To use a distributed cache such as redis or memcached you would inject a
81+
cache object into `uw_saml2.auth.CACHE`. Here's an example of how to do it...
82+
83+
```python
84+
import werkzeug.contrib.cache
85+
import uw_saml2.auth
86+
87+
uw_saml2.auth.CACHE = werkzeug.contrib.cache.RedisCache()
88+
```
89+
90+
Django's cache backend uses the same methods so that could be injected as well.

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@
2424
license='Apache License, Version 2.0',
2525
packages=find_packages(),
2626
include_package_data=True,
27+
install_requires=['Werkzeug'],
2728
extras_require={'python3-saml': saml_requires, 'test': tests_require}
2829
)

tests/test_auth.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import uw_saml2
2+
import uw_saml2.auth
23
from onelogin.saml2.utils import OneLogin_Saml2_Utils
34
import pytest
45
import mock
@@ -52,6 +53,21 @@ def test_process_response(mock_time_and_id):
5253
assert attributes == expected_attributes
5354

5455

56+
def test_process_response_replay(mock_time_and_id):
57+
with open(os.path.join(BASEDIR, 'samlresponse.txt')) as fd:
58+
post = dict(urllib.parse.parse_qsl(fd.read()))
59+
# set a time when this response was still valid
60+
mock_time_and_id.return_value = 1549304000
61+
entity_id = 'https://samldemo.iamdev.s.uw.edu/saml'
62+
acs_url = 'https://samldemo.iamdev.s.uw.edu/saml/login'
63+
attributes = uw_saml2.process_response(post, entity_id=entity_id,
64+
acs_url=acs_url)
65+
with pytest.raises(uw_saml2.auth.SamlResponseError) as excinfo:
66+
attributes = uw_saml2.process_response(post, entity_id=entity_id,
67+
acs_url=acs_url)
68+
assert 'replay' in str(excinfo.value).lower()
69+
70+
5571
@pytest.fixture
5672
def mock_time_and_id(monkeypatch):
5773
"""Mock the two things guaranteed to be nondeterministic - date and id."""
@@ -60,4 +76,5 @@ def mock_time_and_id(monkeypatch):
6076
mock_time.return_value = 1549302384
6177
monkeypatch.setattr(f'{utils}.now', mock_time)
6278
monkeypatch.setattr(f'{utils}.generate_unique_id', lambda: 'FOOBAR123')
79+
uw_saml2.auth.CACHE.clear()
6380
return mock_time

uw_saml2/auth.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""UW-specific adapter for the python3-saml package."""
2+
import werkzeug.contrib.cache
23
from .python3_saml import get_saml_authenticator
34
from .idp.uw import UwIdp
45
from .sp import Config, TWO_FACTOR_CONTEXT
56
from .idp import attribute
67
from logging import getLogger
78
logger = getLogger(__name__)
89

10+
# For distributed environments, inject a distributed cache.
11+
CACHE = werkzeug.contrib.cache.SimpleCache()
12+
913

1014
def login_redirect(entity_id=None, acs_url=None, return_to='/',
1115
force_authn=False, idp=UwIdp, two_factor=False):
@@ -55,6 +59,11 @@ def process_response(post, entity_id=None, acs_url=None, idp=UwIdp,
5559
errors = auth.get_errors()
5660
if errors:
5761
raise SamlResponseError(auth.get_last_error_reason())
62+
63+
message_id = auth.get_last_message_id()
64+
if not CACHE.add(f'uw_saml2:response:message_id:{message_id}', True):
65+
raise SamlResponseError(f'SAML Replay of {message_id}')
66+
5867
attribute_data = dict(attribute.map(auth.get_attributes(), idp=idp))
5968

6069
authn_contexts = auth.get_last_authn_contexts()

uw_saml2/mock.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import urllib.parse
2+
from random import random
23
from .idp import uw, federated
34
MOCK_LOGIN_URL = '/mock-login'
45

@@ -18,7 +19,7 @@ def process_response(self):
1819
idp = self.request['post_data'].get('idp')
1920
if idp != self.idp:
2021
self.errors.append(f'idp mismatch {idp} != {self.idp}')
21-
return
22+
self.message_id = f'MOCKSAML{int(random() * 10**8)}'
2223

2324
def get_attributes(self):
2425
"""Just reflect what's posted right back."""
@@ -41,3 +42,6 @@ def get_last_error_reason(self):
4142

4243
def get_last_authn_contexts(self):
4344
return []
45+
46+
def get_last_message_id(self):
47+
return self.message_id

0 commit comments

Comments
 (0)